fixture = IdentityFixture::create(); } private function login(string $kind = 'manager'): void { $this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk(); } private function select(?int $company = null, ?int $unit = null): string { return $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $company ?? $this->fixture['company'], 'unit_id' => $unit]) ->assertOk()->json('context.token'); } public function test_real_credential_login_invalid_password_and_logout(): void { $this->postJson('/api/login', ['email' => $this->fixture['manager']->email, 'password' => 'incorrect'])->assertUnprocessable(); $this->login(); $this->select(); $this->getJson('/api/context')->assertOk(); $this->postJson('/api/logout')->assertOk(); $this->getJson('/api/context')->assertUnauthorized(); $this->getJson('/api/session')->assertJsonPath('user', null); } public function test_login_rate_limit(): void { for ($i = 0; $i < 5; $i++) { $this->postJson('/api/login', ['email' => $this->fixture['reader']->email, 'password' => 'incorrect'])->assertUnprocessable(); } $this->postJson('/api/login', ['email' => $this->fixture['reader']->email, 'password' => 'incorrect'])->assertStatus(429); } public function test_session_id_rotates_on_login_and_logout(): void { $this->getJson('/api/session'); $before = session()->getId(); $this->login(); $loggedIn = session()->getId(); $this->assertNotSame($before, $loggedIn); $this->postJson('/api/logout')->assertOk(); $this->assertNotSame($loggedIn, session()->getId()); } public function test_reader_cannot_view_other_company_or_hidden_unit_or_write(): void { $this->login('reader'); $companies = $this->getJson('/api/session')->assertOk()->json('companies'); $this->assertCount(1, $companies); $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling']])->assertForbidden(); $this->postJson('/api/context', ['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign']])->assertForbidden(); $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $this->fixture['hidden']])->assertForbidden(); $token = $this->select(unit: $this->fixture['unit']); $this->getJson('/api/companies/'.$this->fixture['company'])->assertOk(); $this->getJson('/api/companies/'.$this->fixture['sibling'])->assertForbidden(); $this->getJson('/api/units/'.$this->fixture['unit'])->assertOk(); $this->getJson('/api/units/'.$this->fixture['hidden'])->assertForbidden(); $this->assertCount(1, $this->getJson('/api/session')->json('context.units')); $this->withHeader('X-SGF-Context', $token)->patchJson('/api/companies/'.$this->fixture['company'], ['name' => 'Forbidden'])->assertForbidden(); $this->withHeader('X-SGF-Context', $token)->patchJson('/api/units/'.$this->fixture['unit'], ['name' => 'Forbidden'])->assertForbidden(); $this->withHeader('X-SGF-Context', $token)->postJson('/api/units', ['name' => 'Forbidden'])->assertForbidden(); $this->getJson('/api/accesses')->assertForbidden(); } public function test_authorized_rename_creation_and_audit(): void { $this->login(); $token = $this->select(unit: $this->fixture['unit']); $this->withHeader('X-SGF-Context', $token)->patchJson('/api/companies/'.$this->fixture['company'], ['name' => 'Empresa actualizada'])->assertOk(); $this->withHeader('X-SGF-Context', $token)->patchJson('/api/units/'.$this->fixture['unit'], ['name' => 'Unidade actualizada'])->assertOk(); $new = $this->withHeader('X-SGF-Context', $token)->postJson('/api/units', ['name' => 'Nova unidade'])->assertCreated()->json('id'); $this->assertDatabaseHas('permission_grants', ['user_id' => $this->fixture['manager']->id, 'unit_id' => $new, 'permission' => 'unit.view']); $this->assertDatabaseHas('permission_grants', ['user_id' => $this->fixture['manager']->id, 'unit_id' => $new, 'permission' => 'unit.edit']); $this->assertDatabaseHas('identity_audit', ['action' => 'company.renamed']); $this->assertDatabaseHas('identity_audit', ['action' => 'access.granted_on_creation', 'unit_id' => $new]); $this->assertStringNotContainsString($this->fixture['password'], json_encode(DB::table('identity_audit')->get())); } public function test_old_form_token_is_rejected_after_switching_company_or_unit(): void { $this->login(); $old = $this->select(unit: $this->fixture['unit']); $this->select($this->fixture['sibling']); $this->getJson('/api/session')->assertJsonPath('context.unit', null); $this->withHeader('X-SGF-Context', $old)->patchJson('/api/companies/'.$this->fixture['company'], ['name' => 'Stale'])->assertStatus(409); $fresh = $this->select(unit: $this->fixture['unit']); $this->select(); $this->withHeader('X-SGF-Context', $fresh)->patchJson('/api/units/'.$this->fixture['unit'], ['name' => 'Stale'])->assertStatus(409); } public function test_manipulated_identifier_and_payload_are_rejected(): void { $this->login(); $token = $this->select(unit: $this->fixture['unit']); $this->withHeader('X-SGF-Context', $token)->patchJson('/api/companies/'.$this->fixture['sibling'], ['name' => 'Cross'])->assertForbidden(); $this->withHeader('X-SGF-Context', $token)->patchJson('/api/units/'.$this->fixture['hidden'], ['name' => 'Cross'])->assertStatus(409); $this->withHeader('X-SGF-Context', $token)->patchJson('/api/units/'.$this->fixture['unit'], ['name' => 'Cross', 'company_id' => $this->fixture['sibling']])->assertUnprocessable(); } public function test_manager_cannot_delegate_rights_they_do_not_have(): void { $this->login('limited'); $token = $this->select(unit: $this->fixture['unit']); $this->withHeader('X-SGF-Context', $token)->postJson('/api/accesses', ['email' => $this->fixture['reader']->email, 'permission' => 'company.edit', 'reason' => 'Synthetic test'])->assertForbidden(); $this->withHeader('X-SGF-Context', $token)->postJson('/api/accesses', ['email' => $this->fixture['reader']->email, 'permission' => 'unit.view', 'unit_id' => $this->fixture['hidden'], 'reason' => 'Synthetic test'])->assertForbidden(); } public function test_grants_and_revocations_take_effect_on_next_request(): void { $this->login(); $token = $this->select(unit: $this->fixture['unit']); $payload = ['email' => $this->fixture['reader']->email, 'permission' => 'company.edit', 'reason' => 'Synthetic test']; $this->withHeader('X-SGF-Context', $token)->postJson('/api/accesses', $payload)->assertOk(); $this->withHeader('X-SGF-Context', $token)->postJson('/api/accesses/revoke', $payload)->assertOk(); $this->login('reader'); $readerToken = $this->select(unit: $this->fixture['unit']); $this->withHeader('X-SGF-Context', $readerToken)->patchJson('/api/companies/'.$this->fixture['company'], ['name' => 'Denied'])->assertForbidden(); DB::table('permission_grants')->where('user_id', $this->fixture['reader']->id)->where('permission', 'company.view')->delete(); $this->getJson('/api/context')->assertForbidden(); } public function test_password_recovery_version_invalidates_existing_session(): void { $this->login(); $this->select(); DB::table('users')->where('id', $this->fixture['manager']->id)->increment('auth_version'); $this->app['auth']->forgetGuards(); $this->getJson('/api/context')->assertUnauthorized(); } public function test_database_blocks_bulk_transfer_of_unit_ownership(): void { $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_IMMUTABLE_OWNERSHIP'); DB::table('units')->where('id', $this->fixture['unit'])->update(['company_id' => $this->fixture['sibling']]); } public function test_database_blocks_bulk_transfer_of_company_ownership(): void { $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_IMMUTABLE_OWNERSHIP'); DB::table('companies')->where('id', $this->fixture['company'])->update(['tenant_id' => $this->fixture['otherTenant']]); } public function test_audit_is_append_only(): void { $this->login(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT'); DB::table('identity_audit')->delete(); } public function test_database_blocks_bulk_transfer_of_membership(): void { $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_IMMUTABLE_OWNERSHIP'); DB::table('company_memberships')->where('user_id', $this->fixture['reader']->id)->update(['company_id' => $this->fixture['sibling']]); } public function test_database_blocks_bulk_transfer_of_permission(): void { $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_IMMUTABLE_OWNERSHIP'); DB::table('permission_grants')->where('user_id', $this->fixture['reader']->id)->where('permission', 'unit.view')->update(['unit_id' => $this->fixture['hidden']]); } public function test_local_provisioning_and_recovery_refuse_the_test_database(): void { $this->artisan('identity:provision', ['--email' => 'never-created@example.test', '--name' => 'Synthetic', '--operator' => 'Test', '--reason' => 'Test']) ->assertExitCode(1); $this->artisan('identity:recover', ['--email' => $this->fixture['reader']->email, '--operator' => 'Test', '--reason' => 'Test']) ->assertExitCode(1); $this->assertDatabaseMissing('users', ['email' => 'never-created@example.test']); } }