Corrige autorizacao transaccional e valida modulos implementados

This commit is contained in:
2026-10-28 17:19:33 +02:00
parent 95dd2cc1ed
commit 5ff8d78803
30 changed files with 601 additions and 97 deletions
@@ -0,0 +1,38 @@
<?php
use App\Models\User;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../../vendor/autoload.php';
$database = $argv[1] ?? '';
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
fwrite(STDERR, 'Base de testes inválida.');
exit(2);
}
$app = require __DIR__.'/../../bootstrap/app.php';
$app->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);
+141
View File
@@ -0,0 +1,141 @@
<?php
use App\Models\GoodsReceipt;
use App\Models\StockMovement;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
// Process-only settings: the project's .env and live database are never rewritten.
foreach (['APP_ENV' => '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);
+11
View File
@@ -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();
});
+18
View File
@@ -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);
});
+1 -3
View File
@@ -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'),
);
@@ -0,0 +1,107 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\Budget;
use App\Models\BudgetConsumption;
use App\Models\Department;
use App\Models\GoodsReceipt;
use App\Models\Invoice;
use App\Models\StockOperation;
use App\Models\Supplier;
use App\Models\SupplierBankDetail;
use App\Models\SupplierBankReview;
use App\Models\TechnicalReview;
use App\Models\User;
use App\Services\EquipmentWorkflow;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierBankWorkflow;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
require_once __DIR__.'/../Support/InvoiceFixture.php';
beforeEach(function () {
$this->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);
});