Files
SGF/tests/Feature/IdentityWorkflowTest.php
T

183 lines
10 KiB
PHP

<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class IdentityWorkflowTest extends TestCase
{
use RefreshDatabase;
private array $fixture;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}