Files
compras/tests/Feature/OrganizationConfigurationTest.php
T
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

262 lines
23 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\RequisitionWorkflow;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ReceiptFixture.php';
require_once __DIR__.'/../Support/AiPlanFixture.php';
function organizationProfile(int $tenant, string $name, array $permissions): int
{
return DB::table('organization_profiles')->insertGetId(['tenant_id' => $tenant, 'name' => $name, 'permissions' => json_encode($permissions, JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
}
function organizationMember(int $tenant, int $user, int $profile): void
{
DB::table('organization_memberships')->insert(['tenant_id' => $tenant, 'user_id' => $user, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now()]);
}
beforeEach(function () {
$this->f = receiptFixture();
$this->admin = $this->f['issuer'];
$this->second = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Requester]);
$this->adminProfile = organizationProfile($this->f['tenant']->id, 'Direcção de sistemas', ['admin']);
organizationMember($this->f['tenant']->id, $this->admin->id, $this->adminProfile);
organizationMember($this->f['tenant']->id, $this->second->id, $this->adminProfile);
DB::table('organization_access')->insert(['tenant_id' => $this->f['tenant']->id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$this->base = '/administracao/parametrizacao';
$this->actingAs($this->admin);
});
test('intelligence settings are independently configurable audited and scoped without rewriting processes', function () {
$before = $this->f['r']->fresh()->toArray();
$body = ['version' => 1, 'procurement_brief' => false, 'people_brief' => true, 'reason' => 'Apresentar sínteses apenas no percurso de Pessoas.'];
$this->postJson($this->base.'/intelligence', $body)->assertRedirect();
expect($this->f['tenant']->fresh()->intelligence_policy)->toBe(['revision' => 1, 'configured' => true, 'procurement_brief' => false, 'people_brief' => true])
->and($this->f['r']->fresh()->toArray())->toBe($before);
$this->get($this->base)->assertInertia(fn (Assert $page) => $page->where('intelligenceReady', true)->where('intelligencePolicy.procurement_brief', false));
$this->get('/requisicoes/'.$this->f['r']->id.'/relatorio')->assertInertia(fn (Assert $page) => $page->where('brief', null)->where('report.requisition.id', $this->f['r']->id));
$this->postJson($this->base.'/intelligence', $body)->assertUnprocessable();
$this->postJson($this->base.'/intelligence', [...$body, 'version' => 2, 'procurement_brief' => true, 'people_brief' => false])->assertRedirect();
$this->get('/requisicoes/'.$this->f['r']->id.'/relatorio')->assertInertia(fn (Assert $page) => $page->where('brief.method', 'procurement_evidence_brief_v1'));
expect($this->f['tenant']->fresh()->intelligence_policy['revision'])->toBe(2)
->and(AuditLog::query()->where('action', 'organization.intelligence')->count())->toBe(2);
});
test('intelligence configuration rejects unauthorised actors secret fields external activation and cross company targets', function () {
$body = ['version' => 1, 'procurement_brief' => false, 'people_brief' => false, 'reason' => 'Configuração de sínteses da organização.'];
foreach (['api_key' => 'DO-NOT-STORE', 'external_processing_enabled' => true, 'tenant_id' => $this->f['tenant']->id + 1, 'procurement_brief' => 'invalid'] as $key => $value) {
$this->postJson($this->base.'/intelligence', [...$body, $key => $value])->assertUnprocessable();
}
$this->actingAs($this->f['receiver'])->postJson($this->base.'/intelligence', $body)->assertForbidden();
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
->and(AuditLog::query()->where('action', 'organization.intelligence')->count())->toBe(0);
});
test('intelligence policy and global version roll back when audit cannot be recorded', function () {
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
$this->withoutExceptionHandling();
expect(fn () => $this->postJson($this->base.'/intelligence', ['version' => 1, 'procurement_brief' => false, 'people_brief' => false, 'reason' => 'Não persistir sem registo de auditoria.']))->toThrow(RuntimeException::class);
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
->and(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1);
});
test('AI plan is versioned audited and preserved when local summary choices change without activating a provider', function () {
Http::fake();
$before = $this->f['r']->fresh()->toArray();
$this->postJson($this->base.'/intelligence', ['version' => 1, 'procurement_brief' => false, 'people_brief' => true, 'reason' => 'Ocultar a síntese local de Compras.'])->assertRedirect();
$this->postJson($this->base.'/ai_plan', [...aiPlanInput(2), 'monthly_budget_usd' => '10'])->assertRedirect();
$policy = $this->f['tenant']->fresh()->intelligence_policy;
expect($policy['procurement_brief'])->toBeFalse()->and($policy['revision'])->toBe(2)
->and($policy['ai_plan']['state'])->toBe('draft')->and($policy['ai_plan']['monthly_budget_usd'])->toBe('10.00');
$this->get($this->base)->assertInertia(fn (Assert $page) => $page->where('aiPlan.provider', 'openai')->where('aiPlan.state', 'draft'));
$this->postJson($this->base.'/intelligence', ['version' => 3, 'procurement_brief' => true, 'people_brief' => false, 'reason' => 'Mudar apresentação sem apagar o plano.'])->assertRedirect();
expect($this->f['tenant']->fresh()->intelligence_policy['ai_plan'])->toBe($policy['ai_plan'])
->and($this->f['r']->fresh()->toArray())->toBe($before)
->and(AuditLog::query()->where('action', 'organization.ai_plan')->count())->toBe(1);
$this->postJson($this->base.'/ai_plan', aiPlanInput(3))->assertUnprocessable();
Http::assertNotSent(fn ($request) => ! in_array(parse_url($request->url(), PHP_URL_HOST), ['127.0.0.1', 'localhost'], true));
});
test('AI plan rejects secret endpoints activation invalid scopes and unbounded or incomplete settings', function () {
foreach ([['api_key' => 'NOT-A-SECRET'], ['endpoint' => 'https://invalid.example'], ['state' => 'active'], ['external_processing_enabled' => true],
['provider' => 'unknown'], ['model' => 'https://invalid.example/model'], ['model' => 'sk-fictitious-not-a-real-key'], ['domains' => ['unknown']], ['data_scopes' => ['raw_candidate_cv']],
['domains' => []], ['monthly_budget_usd' => '0'], ['monthly_requests' => 0], ['max_input_bytes' => 64001], ['max_output_tokens' => 4001]] as $bad) {
$this->postJson($this->base.'/ai_plan', [...aiPlanInput(), ...$bad])->assertUnprocessable();
}
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
->and(AuditLog::query()->where('action', 'organization.ai_plan')->count())->toBe(0);
$this->actingAs($this->f['receiver'])->postJson($this->base.'/ai_plan', aiPlanInput())->assertForbidden();
});
test('AI plan rolls back with the shared configuration version when audit fails', function () {
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
$this->withoutExceptionHandling();
expect(fn () => $this->postJson($this->base.'/ai_plan', aiPlanInput()))->toThrow(RuntimeException::class);
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
->and(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1);
});
test('tax configuration is versioned tenant scoped and never rewrites existing process lines', function () {
$before = $this->f['r']->items()->get()->toArray();
$this->postJson($this->base.'/tax', ['version' => 1, 'default_tax_rate' => '5.00', 'reason' => 'Enquadramento fiscal específico da empresa.'])->assertRedirect();
expect($this->f['tenant']->fresh()->default_tax_rate)->toBe('5.00')
->and($this->f['r']->items()->get()->toArray())->toBe($before);
$this->get('/requisicoes/criar')->assertForbidden();
$this->postJson($this->base.'/tax', ['version' => 1, 'default_tax_rate' => '16', 'reason' => 'Pedido com versão antiga não pode gravar.'])->assertUnprocessable();
$this->postJson($this->base.'/tax', ['version' => 2, 'default_tax_rate' => '101', 'reason' => 'Taxa inválida não pode ser gravada.'])->assertUnprocessable();
expect(AuditLog::query()->where('action', 'organization.tax')->count())->toBe(1);
$this->actingAs($this->f['receiver'])->postJson($this->base.'/tax', ['version' => 2, 'default_tax_rate' => '16', 'reason' => 'Sem competência de administração.'])->assertForbidden();
});
test('comparison policy requires valid weights authority and current version without rewriting existing processes', function () {
$body = ['version' => 1, 'minimum_proposals' => 2, 'minimum_evaluators' => 1, 'weights' => ['price' => 50, 'quality' => 20, 'delivery' => 10, 'history' => 10, 'payment' => 10], 'reason' => 'Regra comercial aprovada para novos processos.'];
$this->postJson($this->base.'/comparison', [...$body, 'weights' => [...$body['weights'], 'price' => 49]])->assertUnprocessable();
expect($this->f['tenant']->fresh()->comparison_policy)->toBeNull();
$this->postJson($this->base.'/comparison', [...$body, 'minimum_evaluators' => 0])->assertUnprocessable();
$this->postJson($this->base.'/comparison', [...$body, 'weights' => [...$body['weights'], 'other' => 0]])->assertUnprocessable();
$this->postJson($this->base.'/comparison', $body)->assertRedirect();
expect($this->f['tenant']->fresh()->comparison_policy['revision'])->toBe(1)->and($this->f['r']->fresh()->comparison_policy)->toBeNull();
$this->postJson($this->base.'/comparison', $body)->assertUnprocessable();
$this->get($this->base)->assertInertia(fn (Assert $page) => $page->where('comparisonPolicy.minimum_proposals', 2));
expect(AuditLog::query()->where('action', 'organization.comparison')->count())->toBe(1);
$this->actingAs($this->f['receiver'])->postJson($this->base.'/comparison', [...$body, 'version' => 2])->assertForbidden();
});
test('setup requires a second named administrator and atomically gives both configured administration', function () {
$new = User::factory()->create();
$this->actingAs($new)->postJson('/empresa/iniciar', ['name' => 'Empresa de entrega QA', 'department' => 'Sede'])->assertUnprocessable()->assertJsonValidationErrors(['second_name', 'second_email', 'second_password']);
$this->post('/empresa/iniciar', ['name' => 'Empresa de entrega QA', 'department' => 'Sede', 'first_limit' => '1000.00', 'second_limit' => '2000.00', 'includes_tax' => true, 'second_name' => 'Segundo administrador QA', 'second_email' => 'segundo-entrega@example.test', 'second_password' => 'QA-Password-123456', 'second_password_confirmation' => 'QA-Password-123456'])->assertRedirect();
$new->refresh();
$second = User::query()->where('email', 'segundo-entrega@example.test')->sole();
expect($second->tenant_id)->toBe($new->tenant_id)->and(Hash::check('QA-Password-123456', $second->password))->toBeTrue();
expect(app(OrganizationAccess::class)->permissions($new))->toBe(['admin'])->and(app(OrganizationAccess::class)->permissions($second))->toBe(['admin']);
$this->actingAs($second)->get($this->base)->assertOk();
});
test('both administrators can access parameters independently of the stored development role', function () {
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('active', true)->where('version', 1)->where('profiles.0.name', 'Direcção de sistemas')->has('users')->missing('users.0.password'));
$this->actingAs($this->second)->get($this->base)->assertOk();
$this->get('/empresa')->assertRedirect($this->base);
expect($this->second->purchasing_role)->toBe(PurchasingRole::Requester);
});
test('custom profile grants a real operational permission and the legacy profile grants nothing after activation', function () {
$this->post($this->base.'/profile', ['version' => 1, 'name' => 'Conferência da empresa', 'permissions' => ['finance']])->assertRedirect();
$profile = (int) DB::table('organization_profiles')->where('name', 'Conferência da empresa')->value('id');
$data = ['version' => 2, 'name' => 'Utilizador configurado', 'email' => 'configurado@example.test', 'password' => 'QA-Password-123456', 'password_confirmation' => 'QA-Password-123456', 'department_id' => $this->f['r']->department_id, 'profile_ids' => [$profile], 'reason' => 'Atribuição aprovada pela empresa no ensaio.'];
$this->post($this->base.'/user', $data)->assertRedirect();
$user = User::query()->where('email', $data['email'])->sole();
expect($user->purchasing_role)->toBe(PurchasingRole::Configured)->and($user->hasFunctions(['finance']))->toBeTrue()->and($user->hasFunctions(['admin']))->toBeFalse();
$this->actingAs($user)->get('/facturas')->assertOk();
$this->get('/requisicoes/criar')->assertForbidden();
$this->get($this->base)->assertForbidden();
$legacy = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
$this->actingAs($legacy)->get('/facturas')->assertForbidden();
$this->get($this->base)->assertForbidden();
});
test('two permanent administrators cannot be removed by assignment profile editing or expiry', function () {
$this->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [], 'maintenance' => false, 'reason' => 'Tentativa de retirar o segundo administrador.'])->assertUnprocessable()->assertJsonValidationErrors('administrators');
$this->postJson($this->base.'/profile', ['version' => 1, 'id' => $this->adminProfile, 'name' => 'Sem poderes', 'permissions' => []])->assertUnprocessable()->assertJsonValidationErrors('administrators');
$this->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [$this->adminProfile], 'maintenance' => false, 'expires_at' => now()->addDay()->toIso8601String(), 'reason' => 'Tentativa de tornar temporário o segundo administrador.'])->assertUnprocessable();
expect(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1)->and(DB::table('organization_memberships')->count())->toBe(2);
});
test('maintenance is named temporary read-only audited and expires without bypassing authentication', function () {
$profile = organizationProfile($this->f['tenant']->id, 'Consulta TEMPO', ['finance']);
$support = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Admin]);
$grant = ['version' => 1, 'user_id' => $support->id, 'profile_ids' => [$profile], 'maintenance' => true, 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Diagnóstico TEMPO identificado e autorizado no ensaio.'];
$this->post($this->base.'/assign', $grant)->assertRedirect();
$this->actingAs($support)->get('/facturas')->assertOk();
expect(AuditLog::query()->where('action', 'maintenance.consulted')->count())->toBe(1);
$this->post('/requisicoes', [])->assertForbidden();
$this->get($this->base)->assertForbidden();
$this->travel(2)->hours();
$this->get('/facturas')->assertForbidden();
auth()->logout();
$this->get('/facturas')->assertRedirect('/login');
});
test('maintenance cannot receive administration and access can be revoked immediately', function () {
$this->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [$this->adminProfile], 'maintenance' => true, 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Tentativa de conceder administração à manutenção.'])->assertUnprocessable();
$profile = organizationProfile($this->f['tenant']->id, 'Compras da empresa', ['procurement']);
organizationMember($this->f['tenant']->id, $this->f['receiver']->id, $profile);
$this->actingAs($this->f['receiver'])->get('/fornecedores')->assertOk();
$this->actingAs($this->admin)->post($this->base.'/assign', ['version' => 1, 'user_id' => $this->f['receiver']->id, 'profile_ids' => [], 'maintenance' => false, 'reason' => 'Revogação imediata da atribuição de teste.'])->assertRedirect();
$this->actingAs($this->f['receiver'])->get('/fornecedores')->assertForbidden();
});
test('foreign profiles users forged permissions and stale configuration cannot be used', function () {
$other = receiptFixture();
$foreign = organizationProfile($other['tenant']->id, 'Perfil alheio', ['admin']);
$this->actingAs($this->admin)->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [$foreign], 'maintenance' => false, 'reason' => 'Tentativa sobre um perfil de outra empresa.'])->assertUnprocessable();
$this->post($this->base.'/assign', ['version' => 1, 'user_id' => $other['issuer']->id, 'profile_ids' => [$this->adminProfile], 'maintenance' => false, 'reason' => 'Tentativa sobre utilizador de outra empresa.'])->assertNotFound();
$this->postJson($this->base.'/profile', ['version' => 1, 'name' => 'Forjado', 'permissions' => ['superuser']])->assertUnprocessable();
$this->postJson($this->base.'/profile', ['version' => 1, 'name' => 'Forjado', 'permissions' => ['finance'], 'tenant_id' => $other['tenant']->id])->assertUnprocessable();
$this->post($this->base.'/profile', ['version' => 1, 'name' => 'Perfil novo', 'permissions' => ['finance']])->assertRedirect();
$this->postJson($this->base.'/profile', ['version' => 1, 'name' => 'Sobreposição', 'permissions' => ['finance']])->assertUnprocessable()->assertJsonValidationErrors('version');
});
test('activation requires two assigned administrators and replaces the previous role authority', function () {
DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->update(['active' => false]);
DB::table('tenants')->where('id', $this->f['tenant']->id)->update(['rules_provisional' => false]);
DB::table('users')->where('id', $this->admin->id)->update(['purchasing_role' => 'admin']);
DB::table('organization_memberships')->where('user_id', $this->second->id)->delete();
$this->actingAs($this->admin->refresh())->postJson($this->base.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Activação da configuração escolhida pela empresa.'])->assertUnprocessable();
organizationMember($this->f['tenant']->id, $this->second->id, $this->adminProfile);
$this->post($this->base.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Activação da configuração escolhida pela empresa.'])->assertRedirect();
expect(app(OrganizationAccess::class)->active($this->f['tenant']->id))->toBeTrue();
$this->actingAs($this->f['receiver'])->get('/recepcoes')->assertForbidden();
});
test('configuration audit failure rolls a profile and its version back', function () {
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
$this->withoutExceptionHandling();
expect(fn () => $this->post($this->base.'/profile', ['version' => 1, 'name' => 'Não persistir', 'permissions' => ['finance']]))->toThrow(RuntimeException::class);
expect(DB::table('organization_profiles')->where('name', 'Não persistir')->exists())->toBeFalse()->and(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1);
});
test('company rules replace provisional defaults exactly and preserve existing submitted snapshots', function () {
$before = $this->f['r']->refresh()->approval_snapshot;
$this->post($this->base.'/rules', ['version' => 1, 'first_limit' => '1234.56', 'second_limit' => '9876.54', 'includes_tax' => false, 'reason' => 'Limites expressamente definidos pela empresa no ensaio.'])->assertRedirect();
$company = $this->f['tenant']->refresh();
expect($company->first_limit_minor)->toBe(123456)->and($company->second_limit_minor)->toBe(987654)->and($company->rules_provisional)->toBeFalse()->and($company->approval_includes_tax)->toBeFalse();
expect($this->f['r']->refresh()->approval_snapshot)->toBe($before);
$this->postJson($this->base.'/rules', ['version' => 2, 'first_limit' => '2000.00', 'second_limit' => '1000.00', 'includes_tax' => true, 'reason' => 'Intervalo incoerente deve ser recusado.'])->assertUnprocessable()->assertJsonValidationErrors('second_limit');
});
test('maintenance profile cannot acquire administration by editing its permissions', function () {
$profile = organizationProfile($this->f['tenant']->id, 'Diagnóstico temporário', ['finance']);
$support = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id]);
$this->post($this->base.'/assign', ['version' => 1, 'user_id' => $support->id, 'profile_ids' => [$profile], 'maintenance' => true, 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Acesso temporário nominativo para diagnóstico.'])->assertRedirect();
$this->postJson($this->base.'/profile', ['version' => 2, 'id' => $profile, 'name' => 'Diagnóstico temporário', 'permissions' => ['finance', 'admin']])->assertUnprocessable()->assertJsonValidationErrors('permissions');
expect($support->hasFunctions(['admin']))->toBeFalse();
});
test('the product default seeder does not create demonstration accounts or change existing users', function () {
$before = User::query()->orderBy('id')->get()->map(fn (User $u) => $u->getAttributes())->all();
$this->seed(DatabaseSeeder::class);
expect(User::query()->orderBy('id')->get()->map(fn (User $u) => $u->getAttributes())->all())->toBe($before);
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
});
test('a configured account has no fallback development permissions while activation is pending', function () {
$staged = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Configured]);
organizationMember($this->f['tenant']->id, $staged->id, $this->adminProfile);
DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->update(['active' => false]);
$this->actingAs($staged)->get($this->base)->assertForbidden();
$this->get('/requisicoes/criar')->assertForbidden();
expect(app(OrganizationAccess::class)->canRequest($staged))->toBeFalse();
DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->update(['active' => true]);
$this->get($this->base)->assertOk();
});