From 5ff8d78803e7db46f5c581da0415d38bf0b1ee2b Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 28 Oct 2026 17:19:33 +0200 Subject: [PATCH] Corrige autorizacao transaccional e valida modulos implementados --- app/Http/Controllers/BudgetController.php | 5 +- .../Settings/SecurityController.php | 2 +- app/Services/CurrentActor.php | 25 ++++ app/Services/EquipmentWorkflow.php | 10 +- app/Services/InvoiceWorkflow.php | 2 + app/Services/PurchaseWorkflow.php | 5 +- app/Services/ReceiptWorkflow.php | 4 +- app/Services/RequisitionWorkflow.php | 10 ++ app/Services/StockWorkflow.php | 1 + app/Services/SupplierBankWorkflow.php | 2 + docs/autorizacao-concorrencia-postgresql.json | 19 +++ docs/bancos-concorrencia-postgresql.json | 2 +- docs/compatibilidade.md | 9 ++ docs/desenvolvimento.md | 21 +++ docs/facturas-concorrencia-postgresql.json | 2 +- .../investimento-concorrencia-postgresql.json | 2 +- docs/orcamento-concorrencia-postgresql.json | 2 +- docs/pagamentos-concorrencia-postgresql.json | 2 +- docs/recepcao-concorrencia-postgresql.json | 68 ++++----- docs/reforcos-concorrencia-postgresql.json | 4 +- docs/stock-concorrencia-postgresql.json | 74 ++++----- docs/validacao-tecnica.md | 83 +++++++++++ resources/js/pages/settings/security.tsx | 24 +-- tests/Concurrency/authorization-worker.php | 38 +++++ tests/Concurrency/run-authorization.php | 141 ++++++++++++++++++ tests/Feature/BudgetTest.php | 11 ++ tests/Feature/PurchaseAwardOrderTest.php | 18 +++ tests/Feature/Settings/SecurityTest.php | 4 +- .../Feature/TransactionAuthorizationTest.php | 107 +++++++++++++ vite.config.ts | 1 + 30 files changed, 601 insertions(+), 97 deletions(-) create mode 100644 app/Services/CurrentActor.php create mode 100644 docs/autorizacao-concorrencia-postgresql.json create mode 100644 docs/validacao-tecnica.md create mode 100644 tests/Concurrency/authorization-worker.php create mode 100644 tests/Concurrency/run-authorization.php create mode 100644 tests/Feature/TransactionAuthorizationTest.php diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index 81f8e08..d9c071e 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -9,6 +9,7 @@ use App\Models\BudgetEntry; use App\Models\BudgetReinforcement; use App\Models\CostCenter; use App\Services\ConflictGuard; +use App\Services\CurrentActor; use App\Services\RequisitionWorkflow; use Brick\Math\BigDecimal; use Illuminate\Http\RedirectResponse; @@ -37,7 +38,7 @@ class BudgetController extends Controller $total = $budget->approvedTotalMinor(); - return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $total, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $total - $reserved - $committed - $consumed]; + return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $budget->approved_by ? $total : 0, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0]; }), 'reinforcements' => BudgetReinforcement::query()->latest('id')->paginate(20)]); } @@ -48,6 +49,7 @@ class BudgetController extends Controller $kind = $request->validate(['kind' => ['sometimes', Rule::in(['operating', 'investment'])]])['kind'] ?? 'operating'; DB::transaction(function () use ($request, $data, $kind) { app(ConflictGuard::class)->lockCompany($request->user()); + app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]); abort_if(Budget::query()->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists(), 409, 'Já existe dotação para este centro e exercício.'); $budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'kind' => $kind, 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]); app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray()); @@ -61,6 +63,7 @@ class BudgetController extends Controller $this->access($request); DB::transaction(function () use ($request, $budget) { app(ConflictGuard::class)->lockCompany($request->user()); + app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]); $budget = Budget::query()->lockForUpdate()->findOrFail($budget->id); abort_if($budget->created_by === $request->user()->id, 403); if ($budget->approved_by) { diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 3811fe9..9582a37 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -33,7 +33,7 @@ class SecurityController extends Controller 'password' => $request->password, ]); - Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]); + Inertia::flash('toast', ['type' => 'success', 'message' => __('Palavra-passe actualizada.')]); return back(); } diff --git a/app/Services/CurrentActor.php b/app/Services/CurrentActor.php new file mode 100644 index 0000000..7651ff5 --- /dev/null +++ b/app/Services/CurrentActor.php @@ -0,0 +1,25 @@ +|null $roles + */ + public function lock(User $actor, ?array $roles = null): User + { + abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403); + if (DB::connection()->transactionLevel() === 0) { + throw new \LogicException('Revalidar o operador dentro da transacção.'); + } + $user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($actor->id); + abort_unless($roles === null || in_array($user->purchasing_role, $roles, true), 403); + + return $user; + } +} diff --git a/app/Services/EquipmentWorkflow.php b/app/Services/EquipmentWorkflow.php index 7270275..91ad74c 100644 --- a/app/Services/EquipmentWorkflow.php +++ b/app/Services/EquipmentWorkflow.php @@ -66,6 +66,7 @@ class EquipmentWorkflow { return DB::transaction(function () use ($user, $requisition, $basis, $decision, $reason) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::DepartmentHead]); $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); abort_unless($this->canReview($user, $requisition), 403); app(ConflictGuard::class)->assertParticipation($user, $requisition); @@ -88,7 +89,14 @@ class EquipmentWorkflow return; } $review = TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $this->basis($requisition))->first(); - $user = $review ? User::query()->where('tenant_id', $requisition->tenant_id)->find($review->reviewed_by) : null; + $user = null; + if ($review) { + $query = User::query()->where('tenant_id', $requisition->tenant_id); + if (DB::connection()->transactionLevel() > 0) { + $query->lockForUpdate(); + } + $user = $query->find($review->reviewed_by); + } if (! $review || $review->decision !== 'approved' || ! $user || ! $this->eligible($user, $requisition)) { throw ValidationException::withMessages(['technical' => 'É necessária validação técnica válida do responsável da área, distinto do solicitante, para a versão actual.']); } diff --git a/app/Services/InvoiceWorkflow.php b/app/Services/InvoiceWorkflow.php index f1afd82..f14ff4c 100644 --- a/app/Services/InvoiceWorkflow.php +++ b/app/Services/InvoiceWorkflow.php @@ -75,6 +75,7 @@ class InvoiceWorkflow return DB::transaction(function () use ($user, $order, $data) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]); $order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id); $request = Requisition::query()->findOrFail($order->requisition_id); app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user); @@ -107,6 +108,7 @@ class InvoiceWorkflow abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403); DB::transaction(function () use ($user, $invoice) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]); $invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id); if ($invoice->status === 'validated') { return; diff --git a/app/Services/PurchaseWorkflow.php b/app/Services/PurchaseWorkflow.php index 84fe9f9..b03e24a 100644 --- a/app/Services/PurchaseWorkflow.php +++ b/app/Services/PurchaseWorkflow.php @@ -26,6 +26,7 @@ class PurchaseWorkflow { return DB::transaction(function () use ($user, $requisition, $data) { $this->guard->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]); $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403); if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) { @@ -73,6 +74,7 @@ class PurchaseWorkflow { DB::transaction(function () use ($user, $award, $decision, $comment) { $this->guard->lockCompany($user); + $user = app(CurrentActor::class)->lock($user); $requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id); $award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id); abort_unless($this->canDecide($user, $award, $requisition), 403); @@ -94,6 +96,7 @@ class PurchaseWorkflow { return DB::transaction(function () use ($user, $award) { $this->guard->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]); abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403); $requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id); $award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id); @@ -262,7 +265,7 @@ class PurchaseWorkflow throw ValidationException::withMessages(['approvals' => 'O ciclo de aprovação do valor final está incompleto.']); } foreach ($steps as $index => $step) { - $user = User::query()->where('tenant_id', $requisition->tenant_id)->find($step->approver_id); + $user = User::query()->where('tenant_id', $requisition->tenant_id)->lockForUpdate()->find($step->approver_id); if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role || ($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) { throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']); diff --git a/app/Services/ReceiptWorkflow.php b/app/Services/ReceiptWorkflow.php index ceeee7b..0874796 100644 --- a/app/Services/ReceiptWorkflow.php +++ b/app/Services/ReceiptWorkflow.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Enums\PurchasingRole; use App\Models\GoodsReceipt; use App\Models\GoodsReceiptItem; use App\Models\PurchaseOrder; @@ -66,8 +67,9 @@ class ReceiptWorkflow { return DB::transaction(function () use ($user, $order, $data) { $this->conflicts->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Warehouse]); $order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id); - Gate::authorize('create', [GoodsReceipt::class, $order]); + Gate::forUser($user)->authorize('create', [GoodsReceipt::class, $order]); $deliveryNote = Str::squish(Str::upper($data['delivery_note'])); if ($deliveryNote === '') { throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']); diff --git a/app/Services/RequisitionWorkflow.php b/app/Services/RequisitionWorkflow.php index 796352d..70537e6 100644 --- a/app/Services/RequisitionWorkflow.php +++ b/app/Services/RequisitionWorkflow.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\AuditLog; +use App\Models\CostCenter; use App\Models\Requisition; use App\Models\Tenant; use App\Models\User; @@ -18,6 +19,10 @@ class RequisitionWorkflow { return DB::transaction(function () use ($user, $data, $existing) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user); + if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) { + throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']); + } $requisition = $existing ? Requisition::query()->lockForUpdate()->findOrFail($existing->id) : new Requisition; if ($existing) { abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403); @@ -70,8 +75,12 @@ class RequisitionWorkflow { DB::transaction(function () use ($user, $requisition) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user); $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403); + if ($user->department_id !== $requisition->department_id) { + throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']); + } app(ConflictGuard::class)->assertParticipation($user, $requisition); foreach ($requisition->items()->get() as $item) { app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority); @@ -103,6 +112,7 @@ class RequisitionWorkflow { DB::transaction(function () use ($user, $requisition, $decision, $comment) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user); $requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id); $requisition->load('steps'); abort_unless($requisition->canDecide($user), 403); diff --git a/app/Services/StockWorkflow.php b/app/Services/StockWorkflow.php index e026d73..e2195d9 100644 --- a/app/Services/StockWorkflow.php +++ b/app/Services/StockWorkflow.php @@ -32,6 +32,7 @@ class StockWorkflow return DB::transaction(function () use ($actor, $data) { // Same first lock as receipts: includes empty balances and concurrent receipts. app(ConflictGuard::class)->lockCompany($actor); + $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]); $hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR)); $existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first(); if ($existing) { diff --git a/app/Services/SupplierBankWorkflow.php b/app/Services/SupplierBankWorkflow.php index 835a203..a88bbef 100644 --- a/app/Services/SupplierBankWorkflow.php +++ b/app/Services/SupplierBankWorkflow.php @@ -28,6 +28,7 @@ class SupplierBankWorkflow return DB::transaction(function () use ($user, $supplier, $data) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]); $supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id); $this->actor($user, $supplier); $latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first(); @@ -49,6 +50,7 @@ class SupplierBankWorkflow } DB::transaction(function () use ($user, $detail, $decision, $reason) { app(ConflictGuard::class)->lockCompany($user); + $user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]); $supplier = Supplier::query()->lockForUpdate()->findOrFail($detail->supplier_id); $this->actor($user, $supplier); $latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->firstOrFail(); diff --git a/docs/autorizacao-concorrencia-postgresql.json b/docs/autorizacao-concorrencia-postgresql.json new file mode 100644 index 0000000..3519686 --- /dev/null +++ b/docs/autorizacao-concorrencia-postgresql.json @@ -0,0 +1,19 @@ +{ + "tested_at_utc": "2026-10-28T15:16:34+00:00", + "database": "compras_receipts_test_0612e6280f36", + "separate_database": true, + "transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections", + "scenarios": [ + { + "name": "role_revoked_while_waiting", + "passed": true, + "overlapping_lock_waits": 2, + "statuses": [403, 403], + "receipts": 0, + "stock_movements": 0 + } + ], + "passed": true, + "test_database_dropped": true, + "live_user_count_unchanged": true +} diff --git a/docs/bancos-concorrencia-postgresql.json b/docs/bancos-concorrencia-postgresql.json index 0c6eeac..3461a2a 100644 --- a/docs/bancos-concorrencia-postgresql.json +++ b/docs/bancos-concorrencia-postgresql.json @@ -1,5 +1,5 @@ { - "tested_at_utc": "2026-10-28T12:32:47+00:00", + "tested_at_utc": "2026-10-28T15:07:45+00:00", "separate_database": true, "transport": "Independent PHP service transactions and PostgreSQL connections", "scenarios": [ diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md index e43b1e2..624c639 100644 --- a/docs/compatibilidade.md +++ b/docs/compatibilidade.md @@ -23,6 +23,15 @@ Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por em ## Verificação actual +Revisão transversal: **407 aprovados, 2 ignorados (MFA desligado), 2.964 asserções**. +32 cenários concorrentes PostgreSQL aprovados; bases temporárias eliminadas. +Perfis e departamentos são novamente verificados dentro da transacção; +dotações pendentes não apresentam fundos disponíveis. Evidências, âmbito e +pendências em [validação técnica](validacao-tecnica.md). Revisão visual, +cancelamento, MFA e validação operacional integral continuam pendentes. + +### Histórico da entrega de investimento + Regras de investimento entregues por empresa, com parâmetros explícitos e duas aprovações administrativas independentes. Dotação classificada, reserva e Finanças obrigatórias para equipamento; regra congelada e revalidada antes de emitir. diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index efb3485..c9e663e 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,5 +1,26 @@ # Desenvolvimento do SGC +## Revisão transversal de autorização e validação + +Corrigidas verificações de perfil que aconteciam antes de esperar pelo bloqueio +da empresa: o operador é agora recarregado e bloqueado na transacção nos módulos +de facturas, banco, armazém, requisições e compras. Departamento/centro de custo +também são revalidados. Dotação não aprovada deixou de mostrar verba disponível. +Corrigido um teste indevidamente ignorado com MFA desligado e traduzidos os textos +da página de segurança. Configuração de fontes alinhada com a dependência opcional +ausente, sem mudança de fonte ou instalação de pacotes. + +Suite: **407 aprovados, 2 ignorados, 2.964 asserções**. Os dois ignorados são de +MFA activado, que continua desligado. **32 cenários concorrentes PostgreSQL** +aprovados, incluindo revogação real de perfil enquanto dois pedidos aguardam +bloqueio: ambos responderam 403, sem movimentos. Bases temporárias eliminadas. +Sem migrações ou alterações de contas/dados de negócio reais nesta revisão. + +Detalhes das correcções, evidências e pendências estão em +[validação técnica](validacao-tecnica.md). Revisão visual e validação operacional +integral permanecem pendentes. Funcionalidades não implementadas, incluindo +revisão/cancelamento, não foram tratadas como se já existissem. + ## Regras de investimento por empresa Implementadas em `/regras-investimento`, sem montantes predefinidos ou regras diff --git a/docs/facturas-concorrencia-postgresql.json b/docs/facturas-concorrencia-postgresql.json index d04470f..887c2b9 100644 --- a/docs/facturas-concorrencia-postgresql.json +++ b/docs/facturas-concorrencia-postgresql.json @@ -1,5 +1,5 @@ { - "tested_at_utc": "2026-10-28T12:19:17+00:00", + "tested_at_utc": "2026-10-28T15:07:45+00:00", "separate_database": true, "transport": "Independent PHP service transactions and PostgreSQL connections", "scenarios": [ diff --git a/docs/investimento-concorrencia-postgresql.json b/docs/investimento-concorrencia-postgresql.json index eb8a09f..93179a7 100644 --- a/docs/investimento-concorrencia-postgresql.json +++ b/docs/investimento-concorrencia-postgresql.json @@ -1,5 +1,5 @@ { - "tested_at_utc": "2026-10-28T13:24:32+00:00", + "tested_at_utc": "2026-10-28T15:07:45+00:00", "separate_database": true, "transport": "Independent PHP service transactions and PostgreSQL connections", "scenarios": [ diff --git a/docs/orcamento-concorrencia-postgresql.json b/docs/orcamento-concorrencia-postgresql.json index 6c0c224..dfbca3e 100644 --- a/docs/orcamento-concorrencia-postgresql.json +++ b/docs/orcamento-concorrencia-postgresql.json @@ -1,5 +1,5 @@ { - "tested_at_utc": "2026-10-28T12:11:20+00:00", + "tested_at_utc": "2026-10-28T15:07:45+00:00", "separate_database": true, "transport": "Independent PHP service transactions and PostgreSQL connections", "scenarios": [ diff --git a/docs/pagamentos-concorrencia-postgresql.json b/docs/pagamentos-concorrencia-postgresql.json index f156974..95965c1 100644 --- a/docs/pagamentos-concorrencia-postgresql.json +++ b/docs/pagamentos-concorrencia-postgresql.json @@ -1,5 +1,5 @@ { - "tested_at_utc": "2026-10-28T12:49:13+00:00", + "tested_at_utc": "2026-10-28T15:07:45+00:00", "separate_database": true, "transport": "Independent PHP service transactions and PostgreSQL connections", "scenarios": [ diff --git a/docs/recepcao-concorrencia-postgresql.json b/docs/recepcao-concorrencia-postgresql.json index 0e1f22e..b232f7a 100644 --- a/docs/recepcao-concorrencia-postgresql.json +++ b/docs/recepcao-concorrencia-postgresql.json @@ -1,6 +1,6 @@ { - "tested_at_utc": "2026-10-28T10:56:15+00:00", - "database": "compras_receipts_test_84e8492cd8f3", + "tested_at_utc": "2026-10-28T15:07:45+00:00", + "database": "compras_receipts_test_c9a6d4b5eeb5", "separate_database": true, "transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections", "scenarios": [ @@ -9,19 +9,19 @@ "passed": true, "overlapping_lock_waits": 2, "responses": [ - { - "status": 201, - "id": 1, - "created": true, - "error_fields": [], - "backend_pid": 4688 - }, { "status": 422, "id": null, "created": null, "error_fields": ["items"], - "backend_pid": 2068 + "backend_pid": 24512 + }, + { + "status": 201, + "id": 1, + "created": true, + "error_fields": [], + "backend_pid": 15424 } ], "receipts": 1, @@ -34,19 +34,19 @@ "passed": true, "overlapping_lock_waits": 2, "responses": [ - { - "status": 201, - "id": 2, - "created": true, - "error_fields": [], - "backend_pid": 9064 - }, { "status": 200, "id": 2, "created": false, "error_fields": [], - "backend_pid": 1036 + "backend_pid": 6580 + }, + { + "status": 201, + "id": 2, + "created": true, + "error_fields": [], + "backend_pid": 5964 } ], "receipts": 1, @@ -59,19 +59,19 @@ "passed": true, "overlapping_lock_waits": 2, "responses": [ - { - "status": 201, - "id": 4, - "created": true, - "error_fields": [], - "backend_pid": 3020 - }, { "status": 201, "id": 3, "created": true, "error_fields": [], - "backend_pid": 24624 + "backend_pid": 26176 + }, + { + "status": 201, + "id": 4, + "created": true, + "error_fields": [], + "backend_pid": 24244 } ], "receipts": 2, @@ -84,19 +84,19 @@ "passed": true, "overlapping_lock_waits": 2, "responses": [ - { - "status": 422, - "id": null, - "created": null, - "error_fields": ["delivery_note"], - "backend_pid": 22120 - }, { "status": 201, "id": 5, "created": true, "error_fields": [], - "backend_pid": 4720 + "backend_pid": 21524 + }, + { + "status": 422, + "id": null, + "created": null, + "error_fields": ["delivery_note"], + "backend_pid": 25700 } ], "receipts": 1, diff --git a/docs/reforcos-concorrencia-postgresql.json b/docs/reforcos-concorrencia-postgresql.json index b937cf8..079d24a 100644 --- a/docs/reforcos-concorrencia-postgresql.json +++ b/docs/reforcos-concorrencia-postgresql.json @@ -1,5 +1,5 @@ { - "tested_at_utc": "2026-10-28T12:59:44+00:00", + "tested_at_utc": "2026-10-28T15:07:46+00:00", "separate_database": true, "transport": "Independent PHP service transactions and PostgreSQL connections", "scenarios": [ @@ -24,7 +24,7 @@ "passed": true, "overlapping_lock_waits": 2, "approved_total_minor": 12000, - "reserved_minor": 10000, + "reserved_minor": 0, "original_allocation_unchanged": true } ], diff --git a/docs/stock-concorrencia-postgresql.json b/docs/stock-concorrencia-postgresql.json index 22b48bd..8c0d821 100644 --- a/docs/stock-concorrencia-postgresql.json +++ b/docs/stock-concorrencia-postgresql.json @@ -1,6 +1,6 @@ { - "tested_at_utc": "2026-10-28T11:35:31+00:00", - "database": "compras_receipts_test_d97ad8ad1eb7", + "tested_at_utc": "2026-10-28T15:07:45+00:00", + "database": "compras_receipts_test_aa453293fa81", "separate_database": true, "transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections", "scenarios": [ @@ -9,19 +9,19 @@ "passed": true, "overlapping_lock_waits": 2, "responses": [ - { - "status": 201, - "id": 1, - "created": true, - "error_fields": [], - "backend_pid": 5004 - }, { "status": 422, "id": null, "created": null, "error_fields": ["quantity"], - "backend_pid": 27024 + "backend_pid": 23636 + }, + { + "status": 201, + "id": 1, + "created": true, + "error_fields": [], + "backend_pid": 15544 } ], "source_milli": 400, @@ -37,14 +37,14 @@ "id": 2, "created": true, "error_fields": [], - "backend_pid": 7640 + "backend_pid": 19896 }, { "status": 200, "id": 2, "created": false, "error_fields": [], - "backend_pid": 22852 + "backend_pid": 25896 } ], "source_milli": 400, @@ -55,19 +55,19 @@ "passed": true, "overlapping_lock_waits": 2, "responses": [ - { - "status": 201, - "id": 3, - "created": true, - "error_fields": [], - "backend_pid": 25232 - }, { "status": 200, "id": 3, "created": false, "error_fields": [], - "backend_pid": 28856 + "backend_pid": 22516 + }, + { + "status": 201, + "id": 3, + "created": true, + "error_fields": [], + "backend_pid": 24156 } ], "source_milli": 400, @@ -83,18 +83,18 @@ "id": 4, "created": true, "error_fields": [], - "backend_pid": 5268 + "backend_pid": 2604 }, { - "status": 422, - "id": null, - "created": null, - "error_fields": ["quantity"], - "backend_pid": 4288 + "status": 201, + "id": 5, + "created": true, + "error_fields": [], + "backend_pid": 22296 } ], - "source_milli": 400, - "destination_milli": 600 + "source_milli": 500, + "destination_milli": 500 }, { "name": "same_adjustment", @@ -103,17 +103,17 @@ "responses": [ { "status": 200, - "id": 6, + "id": 7, "created": false, "error_fields": [], - "backend_pid": 13608 + "backend_pid": 26392 }, { "status": 201, - "id": 6, + "id": 7, "created": true, "error_fields": [], - "backend_pid": 2320 + "backend_pid": 17308 } ], "source_milli": 800, @@ -129,14 +129,14 @@ "id": null, "created": null, "error_fields": ["count_id"], - "backend_pid": 25516 + "backend_pid": 24416 }, { "status": 422, "id": null, "created": null, "error_fields": ["count_id"], - "backend_pid": 23640 + "backend_pid": 15140 } ], "source_milli": 900, @@ -149,17 +149,17 @@ "responses": [ { "status": 201, - "id": 9, + "id": 10, "created": true, "error_fields": [], - "backend_pid": 18244 + "backend_pid": 4480 }, { "status": 422, "id": null, "created": null, "error_fields": ["quantity"], - "backend_pid": 2452 + "backend_pid": 20176 } ], "source_milli": 400, diff --git a/docs/validacao-tecnica.md b/docs/validacao-tecnica.md new file mode 100644 index 0000000..dd3ab20 --- /dev/null +++ b/docs/validacao-tecnica.md @@ -0,0 +1,83 @@ +# Validação técnica do SGC — 28 de Outubro de 2026 + +Esta revisão cobre os módulos implementados e as pendências documentadas no +repositório. Não equivale a validação operacional integral ou certificação de +todos os requisitos do sistema. Valores e políticas reais continuam a pertencer +a cada empresa; nenhum foi criado nesta revisão. + +## Correcções verificadas + +- Operações que verificavam o perfil antes de esperar pelo bloqueio da empresa + passam a recarregar e bloquear o utilizador dentro da transacção. Abrange + facturas, dados bancários, stock, recepções, requisições, adjudicação, emissão, + validação técnica e dotação inicial. Mudança de perfil ou de empresa durante + a espera não é autorizada pelo perfil antigo em memória. +- Requisição volta a verificar departamento e centro de custo ao guardar e o + departamento do solicitante ao submeter. Decisões departamentais usam o perfil + actual; revisão técnica e aprovadores finais são bloqueados durante as + verificações transaccionais. +- Dotação pendente mostra **zero disponível e zero aprovado**, preservando o + valor proposto e o histórico. O servidor já bloqueava a reserva; a interface + deixou de apresentar verba proposta como disponível. +- Teste da página de segurança com MFA desligado deixou de ser indevidamente + ignorado. Textos dessa página e confirmação de alteração de palavra-passe + traduzidos para português. Nenhuma palavra-passe real foi alterada. +- Configuração de fontes passou a declarar desactivada a optimização opcional + que já não era gerada por ausência de `fontaine`. Mantém Instrument Sans e + elimina o aviso de dependência opcional sem instalar pacotes novos. + +## Resultados + +**407 testes aprovados, 2 ignorados, 2.964 asserções** na suite completa SQLite +em memória. Os dois ignorados dependem de MFA activado, actualmente desligado; +não são apresentados como funcionalidades validadas. PHPStan, Pint, TypeScript, +compilação e formatação/lint frontend verificados no fecho. + +**32 cenários concorrentes PostgreSQL aprovados**, todos em bases exclusivas +de teste, eliminadas no final: + +| Área | Cenários | Evidência | +| ------------------------------ | -------: | ------------------------------------------------------ | +| Recepções | 4 | [Relatório](recepcao-concorrencia-postgresql.json) | +| Stock e peças | 7 | [Relatório](stock-concorrencia-postgresql.json) | +| Reservas e compromissos | 5 | [Relatório](orcamento-concorrencia-postgresql.json) | +| Facturas e consumo | 3 | [Relatório](facturas-concorrencia-postgresql.json) | +| Dados bancários | 3 | [Relatório](bancos-concorrencia-postgresql.json) | +| Pagamentos externos | 3 | [Relatório](pagamentos-concorrencia-postgresql.json) | +| Reforços | 3 | [Relatório](reforcos-concorrencia-postgresql.json) | +| Regras de investimento | 3 | [Relatório](investimento-concorrencia-postgresql.json) | +| Perfil revogado durante espera | 1 | [Relatório](autorizacao-concorrencia-postgresql.json) | + +O último ensaio bloqueia a empresa, observa duas ligações simultaneamente à +espera e revoga os dois perfis de Armazém antes de libertar o bloqueio. Os dois +pedidos pelo kernel HTTP retornam **403**; não criam recepções nem movimentos. +Os restantes ensaios verificam saldos, duplicações e integridade conforme os +respectivos relatórios. Não são testes de carga ou uma validação de todos os +possíveis cruzamentos concorrentes do sistema. + +Sem migrações, seeds, criação de contas, alteração de configuração de ambiente +ou escritas de negócio na base real nesta revisão. Acesso HTTP pode actualizar +sessões. Nenhuma autorização de compra ou transferência bancária foi emitida. + +## O que permanece por tratar + +| Pendência | Situação | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Revisão visual pública, autenticada e impressão | Bloqueada pelo acesso ao navegador; testes HTTP não a substituem. | +| MFA obrigatório e ciclo completo de acessos | Não implementados/activados; dois testes condicionais continuam ignorados. | +| Revisão/cancelamento de adjudicações e ordens | Fluxo e política próprios ainda ausentes. Operações incompatíveis continuam bloqueadas. | +| Reversões, estornos, notas de crédito e devoluções | Funcionalidades ainda não implementadas; não se apagam lançamentos existentes. | +| Regras empresariais | Escalões provisórios, regras fiscais, validade/levantamento de conflitos e excepções exigem decisão da empresa. | +| Investimento agregado e fraccionamento | Apenas tecto por processo suportado; sem agregação/detecção automática. | +| Outras etapas do roteiro | Multimoeda, reposição, tolerâncias/investigação de inventários, manutenção/activos e requisitos de subscrição/hosting continuam no roteiro. | +| Controlos operacionais | Carga, permissões contra SQL administrativo, retenção e recuperação por empresa ainda exigem validação própria. | + +Estas pendências não foram marcadas como concluídas por compilação ou testes +funcionais. O próximo incremento de revisão/cancelamento deve definir e guardar +uma política explícita por empresa, conservar o passado e exigir novas decisões +para processos revistos; não pode autorizar por omissão a alteração de ordens +emitidas ou a libertação de compromissos já consumidos. + +Verificação HTTP local no fecho: **18 páginas públicas responderam 200** e +**13 rotas protegidas responderam 302 sem sessão**. O navegador voltou a devolver +`The browser access host is unavailable`; a revisão visual continua pendente. diff --git a/resources/js/pages/settings/security.tsx b/resources/js/pages/settings/security.tsx index c1e8ae0..8e4247d 100644 --- a/resources/js/pages/settings/security.tsx +++ b/resources/js/pages/settings/security.tsx @@ -19,15 +19,15 @@ export default function Security(props: Props) { return ( <> - + -

Security settings

+

Segurança da conta

- + @@ -89,7 +91,7 @@ export default function Security(props: Props) {
@@ -111,7 +113,7 @@ export default function Security(props: Props) { disabled={processing} data-test="update-password-button" > - Save + Guardar
diff --git a/tests/Concurrency/authorization-worker.php b/tests/Concurrency/authorization-worker.php new file mode 100644 index 0000000..2c9a19c --- /dev/null +++ b/tests/Concurrency/authorization-worker.php @@ -0,0 +1,38 @@ +make(ConsoleKernel::class)->bootstrap(); +if (! $app->environment('testing')) { + throw new LogicException('O worker só pode executar em APP_ENV=testing.'); +} +$config = config('database.connections.pgsql'); +$config['database'] = $database; +$config['url'] = null; +config(['database.default' => 'pgsql', 'database.connections.pgsql' => $config]); +DB::purge('pgsql'); +if (DB::selectOne('select current_database() as name')->name !== $database) { + throw new LogicException('A ligação não corresponde à base de testes.'); +} +DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_authorization_'.$argv[4]]); +$pid = DB::selectOne('select pg_backend_pid() as pid')->pid; +auth()->guard()->setUser(User::query()->findOrFail($argv[2])); +$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR); +$request = Request::create('/ordens/'.$argv[3].'/recepcoes', 'POST', $payload, server: ['HTTP_ACCEPT' => 'application/json']); +$kernel = $app->make(HttpKernel::class); +$response = $kernel->handle($request); +$body = json_decode($response->getContent(), true); +$kernel->terminate($request, $response); +echo json_encode(['status' => $response->getStatusCode(), 'id' => $body['id'] ?? null, 'created' => $body['created'] ?? null, 'error_fields' => array_keys($body['errors'] ?? []), 'backend_pid' => $pid], JSON_THROW_ON_ERROR); diff --git a/tests/Concurrency/run-authorization.php b/tests/Concurrency/run-authorization.php new file mode 100644 index 0000000..3c47009 --- /dev/null +++ b/tests/Concurrency/run-authorization.php @@ -0,0 +1,141 @@ + 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) { + putenv($key.'='.$value); + $_ENV[$key] = $_SERVER[$key] = $value; +} +$app = require __DIR__.'/../../bootstrap/app.php'; +$app->make(Kernel::class)->bootstrap(); +require __DIR__.'/../Support/ReceiptFixture.php'; + +$original = DB::connection(); +if ($original->getDriverName() !== 'pgsql') { + throw new LogicException('Este ensaio exige a ligação PostgreSQL local.'); +} +$originalDatabase = $original->getDatabaseName(); +$effectiveConfig = $original->getConfig(); +$admin = $original->getPdo(); +$database = 'compras_receipts_test_'.bin2hex(random_bytes(6)); +if ($database === $originalDatabase || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) { + throw new LogicException('Nome de base de testes não autorizado.'); +} +$liveUsersBefore = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn(); +$created = false; +$processes = []; +$report = ['tested_at_utc' => gmdate('c'), 'database' => $database, 'separate_database' => true, 'transport' => 'Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections', 'scenarios' => []]; +try { + $admin->exec('CREATE DATABASE "'.$database.'"'); + $created = true; + $isolated = $effectiveConfig; + $isolated['url'] = null; + $isolated['database'] = $database; + // Replace both PostgreSQL aliases in this process and discard any schema facade + // resolved by providers while the live configuration was being read. + config(['database.connections.receipt_concurrency' => $isolated, 'database.connections.pgsql' => $isolated]); + DB::purge('pgsql'); + DB::setDefaultConnection('receipt_concurrency'); + Schema::clearResolvedInstance('db.schema'); + app()->forgetInstance('db.schema'); + if (DB::selectOne('select current_database() as name')->name !== $database) { + throw new LogicException('Migração recusada: ligação não isolada.'); + } + if (Schema::getConnection()->getDatabaseName() !== $database || DB::connection('pgsql')->getDatabaseName() !== $database) { + throw new LogicException('Migração recusada: esquema ou alias PostgreSQL não isolado.'); + } + Artisan::call('migrate', ['--database' => 'receipt_concurrency', '--force' => true]); + $workerEnv = [ + 'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, + 'DB_HOST' => $effectiveConfig['host'], 'DB_PORT' => (string) $effectiveConfig['port'], + 'DB_USERNAME' => $effectiveConfig['username'], 'DB_PASSWORD' => $effectiveConfig['password'], + 'DB_SSLMODE' => $effectiveConfig['sslmode'] ?? 'prefer', + 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', + ]; + foreach (['role_revoked_while_waiting'] as $scenario) { + $fixture = receiptFixture(); + $first = receiptPayload($fixture, '0.600'); + $second = receiptPayload($fixture, '0.600'); + $connection = DB::connection(); + $connection->beginTransaction(); + // Hold the company: both requests cache their old role before we revoke it. + $connection->table('tenants')->where('id', $fixture['tenant']->id)->lockForUpdate()->first(); + $processes = []; + foreach ([$first, $second] as $index => $payload) { + $label = $index === 0 ? 'A' : 'B'; + $user = $index === 0 ? $fixture['receiver'] : $fixture['otherReceiver']; + $process = new Process([PHP_BINARY, __DIR__.'/authorization-worker.php', $database, (string) $user->id, (string) $fixture['order']->id, $label], base_path(), $workerEnv); + $process->setInput(json_encode($payload, JSON_THROW_ON_ERROR)); + $process->setTimeout(45); + $process->start(); + $processes[] = $process; + } + $statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_authorization_A','sgc_authorization_B') AND wait_event_type = 'Lock'"); + $deadline = microtime(true) + 20; + $waits = 0; + do { + $statement->execute([$database]); + $waits = (int) $statement->fetchColumn(); + if ($waits >= 2) { + break; + } + foreach ($processes as $process) { + if (! $process->isRunning()) { + throw new RuntimeException('Worker terminou antes da sobreposição: '.$process->getOutput().' '.$process->getErrorOutput()); + } + } + usleep(20000); + } while (microtime(true) < $deadline); + $connection->table('users')->whereIn('id', [$fixture['receiver']->id, $fixture['otherReceiver']->id])->update(['purchasing_role' => 'requester']); + $connection->commit(); + if ($waits < 2) { + throw new RuntimeException('Não foi demonstrada espera simultânea pelos bloqueios.'); + } + $results = []; + foreach ($processes as $process) { + $process->wait(); + if (! $process->isSuccessful()) { + throw new RuntimeException('Falha do worker: '.$process->getErrorOutput()); + } + $results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + } + $statuses = array_column($results, 'status'); + sort($statuses); + if ($statuses !== [403, 403] || GoodsReceipt::query()->count() !== 0 || StockMovement::query()->count() !== 0 || count(array_unique(array_column($results, 'backend_pid'))) !== 2) { + throw new RuntimeException('A stale warehouse profile authorised a receipt.'); + } + $report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'statuses' => $statuses, 'receipts' => 0, 'stock_movements' => 0]; + } + $report['passed'] = true; +} catch (Throwable $error) { + $report['passed'] = false; + $report['failure'] = $error->getMessage(); +} finally { + foreach ($processes as $process) { + if ($process->isRunning()) { + $process->stop(); + } + } + if (isset($connection) && $connection->transactionLevel() > 0) { + $connection->rollBack(); + } + DB::disconnect('receipt_concurrency'); + DB::disconnect('pgsql'); + if ($created && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database) && $database !== $originalDatabase) { + $admin->exec('DROP DATABASE "'.$database.'"'); + $report['test_database_dropped'] = true; + } + $report['live_user_count_unchanged'] = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn() === $liveUsersBefore; +} +file_put_contents(__DIR__.'/../../docs/autorizacao-concorrencia-postgresql.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL); +echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL; +exit(($report['passed'] ?? false) ? 0 : 1); diff --git a/tests/Feature/BudgetTest.php b/tests/Feature/BudgetTest.php index 09e5a3a..085d199 100644 --- a/tests/Feature/BudgetTest.php +++ b/tests/Feature/BudgetTest.php @@ -149,3 +149,14 @@ test('budget service refuses a requisition belonging to another company', functi } expect(BudgetEntry::query()->count())->toBe(0); }); + +test('unapproved allocation displays no authorised or available funds', function () { + $this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->where('budgets.0.amount_minor', 20000)->where('budgets.0.approved_total_minor', 0)->where('budgets.0.available_minor', 0)); +}); + +test('initial allocation approval rechecks administrator role inside transaction', function () { + $this->actingAs($this->approver); + DB::table('users')->where('id', $this->approver->id)->update(['purchasing_role' => 'warehouse']); + $this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertForbidden(); + expect($this->budget->refresh()->approved_by)->toBeNull(); +}); diff --git a/tests/Feature/PurchaseAwardOrderTest.php b/tests/Feature/PurchaseAwardOrderTest.php index 3269bed..95b9b71 100644 --- a/tests/Feature/PurchaseAwardOrderTest.php +++ b/tests/Feature/PurchaseAwardOrderTest.php @@ -486,3 +486,21 @@ test('equipment cannot bypass technical finance or investment budget requirement $this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors($missing === 'technical' ? 'technical' : ($missing === 'finance' ? 'approvals' : 'investment')); expect(PurchaseAward::query()->count())->toBe(0); })->with(['technical', 'finance', 'budget']); + +test('purchase issuance rechecks a revoked procurement role inside transaction', function () { + $award = selectPurchaseAward($this); + approvePurchaseAward($this, $award); + $this->actingAs($this->buyer); + DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => 'warehouse']); + $this->post('/adjudicacoes/'.$award->id.'/emitir')->assertForbidden(); + expect(PurchaseOrder::query()->count())->toBe(0); +}); + +test('final approval rechecks a department head who moved departments', function () { + $award = selectPurchaseAward($this); + $department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outro departamento fictício']); + $this->actingAs($this->head); + DB::table('users')->where('id', $this->head->id)->update(['department_id' => $department->id]); + $this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden(); + expect($award->approvals()->where('decision', 'approved')->count())->toBe(0); +}); diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index 2333a36..c24f0bb 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -25,8 +25,6 @@ test('security page is displayed', function () { }); test('security page renders without two factor when feature is disabled', function () { - $this->skipUnlessFortifyHas(Features::twoFactorAuthentication()); - config(['fortify.features' => []]); $user = User::factory()->create(); @@ -36,7 +34,7 @@ test('security page renders without two factor when feature is disabled', functi ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('settings/security') - ->where('canManageTwoFactor', false) + ->has('passwordRules') ->missing('twoFactorEnabled') ->missing('requiresConfirmation'), ); diff --git a/tests/Feature/TransactionAuthorizationTest.php b/tests/Feature/TransactionAuthorizationTest.php new file mode 100644 index 0000000..a27cbd0 --- /dev/null +++ b/tests/Feature/TransactionAuthorizationTest.php @@ -0,0 +1,107 @@ +f = receiptFixture(); +}); + +test('receipt revalidates a warehouse operator whose cached profile was revoked', function () { + $this->actingAs($this->f['receiver']); + DB::table('users')->where('id', $this->f['receiver']->id)->update(['purchasing_role' => 'requester']); + $this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f))->assertForbidden(); + expect(GoodsReceipt::query()->count())->toBe(0); +}); + +test('stock issue revalidates warehouse role inside transaction', function () { + $this->actingAs($this->f['receiver']); + app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000')); + DB::table('users')->where('id', $this->f['receiver']->id)->update(['purchasing_role' => 'requester']); + $this->post('/stock/operacoes', ['kind' => 'issue', 'reference' => 'STALE-STOCK', 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['issuer']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'destination' => 'Destino fictício', 'quantity' => '0.100', 'reason' => 'Teste de perfil revogado'])->assertForbidden(); + expect(StockOperation::query()->count())->toBe(0); +}); + +test('invoice registration rechecks finance role after the company lock', function () { + $f = invoiceFixture(); + DB::table('users')->where('id', $f['author']->id)->update(['purchasing_role' => 'warehouse']); + $this->actingAs($f['author'])->post('/ordens/'.$f['order']->id.'/facturas', [...$f['data'], 'number' => 'FT-STALE'])->assertForbidden(); + expect(Invoice::query()->count())->toBe(1); +}); + +test('invoice validation cannot consume budget with a revoked finance role', function () { + $f = invoiceFixture(); + DB::table('users')->where('id', $f['validator']->id)->update(['purchasing_role' => 'warehouse']); + $this->actingAs($f['validator'])->post('/facturas/'.$f['invoice']->id.'/validar')->assertForbidden(); + expect($f['invoice']->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0); +}); + +test('bank proposal rechecks procurement role in the transaction', function () { + $this->actingAs($this->f['issuer']); + DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'warehouse']); + $this->post('/fornecedores/'.$this->f['order']->supplier_id.'/dados-bancarios', ['previous_id' => 0, 'bank_name' => 'Banco fictício', 'country' => 'MZ', 'currency' => 'MZN', 'beneficiary' => 'Titular fictício', 'account_identifier' => 'TEST-STALE-ACCOUNT', 'evidence_reference' => 'Fixture', 'reason' => 'Fixture'])->assertForbidden(); + expect(SupplierBankDetail::query()->count())->toBe(0); +}); + +test('bank review rechecks finance role before accepting a signature', function () { + $this->actingAs($this->f['issuer']); + $supplier = Supplier::query()->findOrFail($this->f['order']->supplier_id); + $detail = app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $supplier, ['previous_id' => 0, 'bank_name' => 'Banco fictício', 'country' => 'MZ', 'currency' => 'MZN', 'beneficiary' => 'Titular fictício', 'account_identifier' => 'TEST-STALE-ACCOUNT', 'evidence_reference' => 'Fixture', 'reason' => 'Fixture']); + $finance = User::factory()->create(['tenant_id' => $supplier->tenant_id, 'purchasing_role' => PurchasingRole::Finance]); + $this->actingAs($finance); + DB::table('users')->where('id', $finance->id)->update(['purchasing_role' => 'warehouse']); + $this->post('/fornecedores/'.$supplier->id.'/dados-bancarios/'.$detail->id.'/validar', ['decision' => 'approved', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden(); + expect(SupplierBankReview::query()->count())->toBe(0); +}); + +test('budget creation rechecks current finance or administration role', function () { + $finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); + $this->actingAs($finance); + DB::table('users')->where('id', $finance->id)->update(['purchasing_role' => 'warehouse']); + $this->post('/orcamentos', ['cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount' => '100.00', 'includes_tax' => true, 'reason' => 'Dotação fictícia'])->assertForbidden(); + expect(Budget::query()->count())->toBe(0); +}); + +test('a moved requester cannot save or submit using the previous department', function () { + $actor = $this->f['issuer']; + $this->actingAs($actor); + $department = Department::query()->create(['tenant_id' => $actor->tenant_id, 'name' => 'Novo departamento fictício']); + DB::table('users')->where('id', $actor->id)->update(['department_id' => $department->id]); + $data = ['cost_center_id' => $this->f['r']->cost_center_id, 'priority' => 'normal', 'needed_by' => today()->addWeek()->format('Y-m-d'), 'justification' => 'Necessidade fictícia para teste de departamento.', 'items' => [['description' => 'Artigo', 'category' => 'Teste', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'cx', 'unit_price' => '1.00', 'tax_rate' => '0']]]; + $this->post('/requisicoes', $data)->assertSessionHasErrors('cost_center_id'); + $request = $this->f['r']; + $request->update(['status' => 'draft']); + expect(fn () => app(RequisitionWorkflow::class)->submit($actor, $request))->toThrow(ValidationException::class); + expect($request->refresh()->status)->toBe('draft')->and($request->steps()->count())->toBe(0); +}); + +test('technical review revalidates the department head current profile', function () { + $request = $this->f['r']; + $request->items()->update(['purchase_type' => 'equipment', 'production_area' => 'Linha fictícia']); + $request->update(['status' => 'draft']); + $request->refresh(); + $head = User::factory()->create(['tenant_id' => $request->tenant_id, 'department_id' => $request->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]); + $this->actingAs($head); + DB::table('users')->where('id', $head->id)->update(['purchasing_role' => 'warehouse']); + $this->post('/requisicoes/'.$request->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($request), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden(); + expect(TechnicalReview::query()->count())->toBe(0); +}); diff --git a/vite.config.ts b/vite.config.ts index f93521c..82b3799 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ fonts: [ bunny('Instrument Sans', { weights: [400, 500, 600], + optimizedFallbacks: false, }), ], }),