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(); });