Preserva base local aceite de identidade e acessos

This commit is contained in:
Codex
2026-10-25 21:29:22 +02:00
commit 26b80df545
112 changed files with 13259 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Modules\Identity\Models\Company;
use App\Modules\Identity\Models\Tenant;
use App\Modules\Identity\Models\Unit;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Tests\TestCase;
final class CompanyIsolationTest extends TestCase
{
use RefreshDatabase;
private function scenario(): array
{
$tenant = Tenant::create(['name' => 'Tenant A']);
$otherTenant = Tenant::create(['name' => 'Tenant B']);
$company = Company::create(['tenant_id' => $tenant->id, 'name' => 'Company A']);
$sibling = Company::create(['tenant_id' => $tenant->id, 'name' => 'Company B']);
$foreign = Company::create(['tenant_id' => $otherTenant->id, 'name' => 'Company C']);
$user = User::create(['name' => 'Synthetic User', 'email' => 'synthetic@example.test', 'password' => 'test-only-password']);
DB::table('company_memberships')->insert(['user_id' => $user->id, 'tenant_id' => $tenant->id, 'company_id' => $company->id]);
\Tests\Support\IdentityFixture::grant($user->id, $tenant->id, $company->id, 'company.view');
return [$user, $tenant, $company, $sibling, $foreign];
}
public function test_membership_is_required_even_for_another_company_in_the_same_tenant(): void
{
[$user, $tenant, $company, $sibling, $foreign] = $this->scenario();
$this->actingAs($user)->withSession(['auth_version' => 1, 'company_context' => ['tenant_id' => $tenant->id, 'company_id' => $company->id, 'unit_id' => null, 'token' => 'synthetic']])
->getJson('/api/context')->assertOk()->assertExactJson(['tenant_id' => $tenant->id, 'company_id' => $company->id, 'unit_id' => null]);
foreach ([$sibling, $foreign] as $denied) {
$this->withSession(['company_context' => ['tenant_id' => $denied->tenant_id, 'company_id' => $denied->id, 'unit_id' => null, 'token' => 'synthetic']])->getJson('/api/context')->assertForbidden();
}
$this->withSession(['company_context' => []])->getJson('/api/context')->assertForbidden();
}
public function test_company_owned_reads_and_bulk_writes_do_not_cross_company_boundaries(): void
{
[$user, $tenant, $company, $sibling] = $this->scenario();
DB::table('units')->insert([
['tenant_id' => $tenant->id, 'company_id' => $company->id, 'name' => 'Visible'],
['tenant_id' => $tenant->id, 'company_id' => $sibling->id, 'name' => 'Private'],
]);
$visibleId = DB::table('units')->where('company_id', $company->id)->value('id');
\Tests\Support\IdentityFixture::grant($user->id, $tenant->id, $company->id, 'unit.view', $visibleId);
$context = app(CompanyContext::class);
$context->selectFor($user->id, $tenant->id, $company->id);
$this->assertSame(['Visible'], Unit::query()->pluck('name')->all());
$this->assertSame(1, Unit::query()->update(['name' => 'Updated']));
$this->assertSame('Private', DB::table('units')->where('company_id', $sibling->id)->value('name'));
$unit = Unit::create(['name' => 'New']);
$this->assertSame($company->id, $unit->company_id);
$context->clear();
$this->expectException(AccessDeniedHttpException::class);
Unit::query()->count();
}
public function test_stale_model_cannot_be_saved_under_another_company_context(): void
{
[$user, $tenant, $company, $sibling] = $this->scenario();
DB::table('company_memberships')->insert(['user_id' => $user->id, 'tenant_id' => $tenant->id, 'company_id' => $sibling->id]);
\Tests\Support\IdentityFixture::grant($user->id, $tenant->id, $sibling->id, 'company.view');
$context = app(CompanyContext::class);
$context->selectFor($user->id, $tenant->id, $company->id);
$unit = Unit::create(['name' => 'Original']);
$context->selectFor($user->id, $tenant->id, $sibling->id);
$unit->name = 'Forbidden';
$this->expectException(AccessDeniedHttpException::class);
$unit->save();
}
public function test_database_rejects_mismatched_tenant_company_relationship(): void
{
[, $tenant, , , $foreign] = $this->scenario();
$this->expectException(\Illuminate\Database\QueryException::class);
DB::table('units')->insert(['tenant_id' => $tenant->id, 'company_id' => $foreign->id, 'name' => 'Invalid']);
}
public function test_context_is_cleared_after_a_request(): void
{
[$user, $tenant, $company] = $this->scenario();
$this->actingAs($user)->withSession(['auth_version' => 1, 'company_context' => ['tenant_id' => $tenant->id, 'company_id' => $company->id, 'unit_id' => null, 'token' => 'synthetic']])->getJson('/api/context')->assertOk();
$this->expectException(AccessDeniedHttpException::class);
app(CompanyContext::class)->identifiers();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Tests\TestCase;
use Symfony\Component\Process\Process;
final class DatabaseSafetyTest extends TestCase
{
public function test_test_runner_has_no_cluster_administration_privileges(): void
{
$role = DB::selectOne('SELECT rolsuper, rolcreatedb, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname = current_user');
$this->assertFalse($role->rolsuper);
$this->assertFalse($role->rolcreatedb);
$this->assertFalse($role->rolcreaterole);
$this->assertFalse($role->rolbypassrls);
$this->assertFalse(DB::selectOne("SELECT has_database_privilege(current_user, 'sgf_tempo_dev', 'CONNECT') AS allowed")->allowed);
}
public function test_guard_rejects_development_before_connecting(): void
{
config(['database.connections.unsafe' => array_merge(config('database.connections.pgsql'), ['database' => 'sgf_tempo_dev'])]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('SGF_TEST_DATABASE_REFUSED');
TestingDatabaseGuard::assertSafe('unsafe');
}
public function test_guard_rejects_connection_url_overrides(): void
{
config(['database.connections.unsafe' => array_merge(config('database.connections.pgsql'), ['url' => 'pgsql://invalid'])]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('SGF_TEST_DATABASE_REFUSED');
TestingDatabaseGuard::assertSafe('unsafe');
}
public function test_destructive_cli_command_refuses_the_development_environment(): void
{
$process = new Process([PHP_BINARY, 'artisan', 'migrate:fresh', '--env=local', '--no-interaction'], base_path(), [
'APP_ENV' => 'local', 'DB_CONNECTION' => false, 'DB_HOST' => false,
'DB_PORT' => false, 'DB_DATABASE' => false, 'DB_USERNAME' => false,
'DB_PASSWORD' => false, 'DB_URL' => false,
]);
$process->run();
$this->assertFalse($process->isSuccessful());
$this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED', $process->getOutput().$process->getErrorOutput());
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
+182
View File
@@ -0,0 +1,182 @@
<?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']);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Tests\Support;
use App\Models\User;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Support\Facades\DB;
final class IdentityFixture
{
public static function create(): array
{
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$now = now();
$tenant = DB::table('tenants')->insertGetId(['name' => 'Cliente de demonstração', 'created_at' => $now, 'updated_at' => $now]);
$otherTenant = DB::table('tenants')->insertGetId(['name' => 'Outro cliente sintético', 'created_at' => $now, 'updated_at' => $now]);
$company = DB::table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Empresa Horizonte — Demonstração', 'created_at' => $now, 'updated_at' => $now]);
$sibling = DB::table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Empresa Maré — Demonstração', 'created_at' => $now, 'updated_at' => $now]);
$foreign = DB::table('companies')->insertGetId(['tenant_id' => $otherTenant, 'name' => 'Empresa externa sintética', 'created_at' => $now, 'updated_at' => $now]);
$unit = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Unidade Central — Demonstração', 'created_at' => $now, 'updated_at' => $now]);
$hidden = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Unidade Reservada', 'created_at' => $now, 'updated_at' => $now]);
$siblingUnit = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $sibling, 'name' => 'Unidade Maré', 'created_at' => $now, 'updated_at' => $now]);
$password = bin2hex(random_bytes(20));
$manager = User::create(['name' => 'Gestor de Demonstração', 'email' => 'gestor@example.test', 'password' => $password]);
$reader = User::create(['name' => 'Consulta de Demonstração', 'email' => 'consulta@example.test', 'password' => $password]);
$limited = User::create(['name' => 'Gestor Limitado', 'email' => 'limitado@example.test', 'password' => $password]);
foreach ([$manager, $reader, $limited] as $user) {
DB::table('company_memberships')->insert(['user_id' => $user->id, 'tenant_id' => $tenant, 'company_id' => $company]);
self::grant($user->id, $tenant, $company, 'company.view');
self::grant($user->id, $tenant, $company, 'unit.view', $unit);
}
foreach (Permissions::COMPANY as $permission) { self::grant($manager->id, $tenant, $company, $permission); }
self::grant($manager->id, $tenant, $company, 'unit.edit', $unit);
self::grant($limited->id, $tenant, $company, 'access.manage');
DB::table('company_memberships')->insert(['user_id' => $manager->id, 'tenant_id' => $tenant, 'company_id' => $sibling]);
self::grant($manager->id, $tenant, $sibling, 'company.view');
self::grant($manager->id, $tenant, $sibling, 'unit.view', $siblingUnit);
return compact('tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'manager', 'reader', 'limited', 'password');
}
public static function grant(int $user, int $tenant, int $company, string $permission, ?int $unit = null): void
{
DB::table('permission_grants')->insertOrIgnore(['user_id' => $user, 'tenant_id' => $tenant, 'company_id' => $company,
'permission' => $permission, 'unit_id' => $unit, 'created_at' => now(), 'updated_at' => now()]);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}
@@ -0,0 +1,47 @@
<?php
namespace Tests\Unit;
use App\Modules\Identity\Services\ProtectedProvisioningInput;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ProtectedProvisioningInputTest extends TestCase
{
private function stream(string $payload)
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, $payload); rewind($stream);
return $stream;
}
public function test_protected_input_preserves_unicode_and_spaces_without_echoing(): void
{
$secret = ' '.bin2hex(random_bytes(12)).'çã! ';
$stream = $this->stream(json_encode(['confirmed' => true, 'email' => 'synthetic@example.test', 'password' => $secret, 'confirmation' => $secret]));
try { $values = ProtectedProvisioningInput::read($stream, 'synthetic@example.test'); }
finally { fclose($stream); }
$this->assertTrue($values[0] === $secret && $values[1] === $secret);
}
public function test_unconfirmed_or_wrong_recipient_is_refused_without_revealing_payload(): void
{
$secret = bin2hex(random_bytes(12));
foreach ([['confirmed' => false, 'email' => 'synthetic@example.test'], ['confirmed' => true, 'email' => 'another@example.test']] as $case) {
$stream = $this->stream(json_encode($case + ['password' => $secret, 'confirmation' => $secret]));
try { ProtectedProvisioningInput::read($stream, 'synthetic@example.test'); $this->fail('Unconfirmed input accepted.'); }
catch (RuntimeException $exception) { $this->assertStringNotContainsString($secret, $exception->getMessage()); }
finally { fclose($stream); }
}
}
public function test_malformed_payload_is_refused_without_including_its_contents(): void
{
$stream = $this->stream('{private-invalid-payload');
try {
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Entrada protegida inválida ou não confirmada. Nada alterado.');
ProtectedProvisioningInput::read($stream, 'synthetic@example.test');
} finally { fclose($stream); }
}
}