232 lines
13 KiB
PHP
232 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use App\Services\PortalProfileAdministration;
|
|
use App\Services\PortalProfileInstallation;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class PortalProfileInstallationTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private string $proofDirectory;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->proofDirectory = storage_path('framework/testing/profile-install-'.Str::uuid());
|
|
mkdir($this->proofDirectory, 0700, true);
|
|
config(['portal_profiles.proof_directory' => $this->proofDirectory, 'portal.profile_access_enabled' => false]);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
foreach (glob($this->proofDirectory.'/*') ?: [] as $file) {
|
|
unlink($file);
|
|
}
|
|
rmdir($this->proofDirectory);
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function manager(): User
|
|
{
|
|
$user = $this->actor();
|
|
config(['portal.managers' => [$user->email]]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function actor(): User
|
|
{
|
|
return User::factory()->create(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()]);
|
|
}
|
|
|
|
private function login(User $user, bool $mfa = true): static
|
|
{
|
|
return $this->actingAs($user)->withSession(['portal.mfa_actor' => $mfa ? $user->id : null, 'portal.mfa_at' => $mfa ? time() : 0]);
|
|
}
|
|
|
|
private function emptySchema(): void
|
|
{
|
|
(require database_path('migrations/'.PortalProfileInstallation::MIGRATION.'.php'))->down();
|
|
DB::table('migrations')->where('migration', PortalProfileInstallation::MIGRATION)->delete();
|
|
}
|
|
|
|
private function data(): array
|
|
{
|
|
return ['hash' => $this->getJson('/management/profile-install')->assertOk()->json('hash'), 'confirm' => true,
|
|
'idempotency_key' => (string) Str::uuid(), 'reason' => 'Instalação sintética em base isolada de testes'];
|
|
}
|
|
|
|
private function readinessFixture(): void
|
|
{
|
|
// Synthetic files exercise validation; they are never deployment backup evidence.
|
|
file_put_contents($this->proofDirectory.'/dump', 'unit-fixture-not-a-real-dump');
|
|
file_put_contents($this->proofDirectory.'/files', 'unit-fixture-not-real-files');
|
|
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
|
file_put_contents($this->proofDirectory.'/readiness.json', json_encode(['verified_at' => gmdate('c'), 'target' => $target,
|
|
'code_hash' => app(PortalProfileInstallation::class)->fingerprint(), 'restore_verified' => true, 'files_verified' => true,
|
|
'qa_passed' => true, 'compatibility_verified' => true, 'dump_path' => $this->proofDirectory.'/dump', 'dump_sha256' => hash_file('sha256', $this->proofDirectory.'/dump'),
|
|
'files_path' => $this->proofDirectory.'/files', 'files_sha256' => hash_file('sha256', $this->proofDirectory.'/files')], JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
public function test_cold_install_repetition_empty_reversal_and_reinstallation_preserve_accounts(): void
|
|
{
|
|
$actor = $this->manager();
|
|
$this->emptySchema();
|
|
$this->assertFalse(Schema::hasColumn('users', 'portal_access_revision'));
|
|
$this->login($actor);
|
|
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('installed', false)->assertJsonPath('consistent', true)->assertJsonPath('ready', false);
|
|
$this->postJson('/management/profile-install', $this->data())->assertUnprocessable();
|
|
$this->readinessFixture();
|
|
$users = DB::table('users')->count();
|
|
$data = $this->data();
|
|
$result = $this->postJson('/management/profile-install', $data)->assertOk()->assertJsonPath('changed', true)->json();
|
|
$this->postJson('/management/profile-install', $data)->assertOk()->assertExactJson($result);
|
|
$this->postJson('/management/profile-install', [...$data, 'reason' => $data['reason'].' different'])->assertConflict();
|
|
$this->postJson('/management/profile-install', $this->data())->assertOk()->assertJsonPath('changed', false);
|
|
$this->assertSame($users, DB::table('users')->count());
|
|
$this->assertSame(0, DB::table('portal_role_assignments')->count());
|
|
$this->assertSame(0, DB::table('portal_superuser_pair')->count());
|
|
$this->assertFalse(config('portal.profile_access_enabled'));
|
|
$this->assertFalse(config('portal_profiles.authenticated_installation'));
|
|
$this->assertSame(2, DB::table('audit_events')->where('source_system', 'portal-profile-installation')->count());
|
|
$this->emptySchema();
|
|
$this->postJson('/management/profile-install', $this->data())->assertOk()->assertJsonPath('changed', true);
|
|
}
|
|
|
|
public function test_dependencies_integrity_stale_preview_and_partial_schema_fail_closed(): void
|
|
{
|
|
$actor = $this->manager();
|
|
$this->emptySchema();
|
|
$this->login($actor);
|
|
$this->readinessFixture();
|
|
$data = $this->data();
|
|
file_put_contents($this->proofDirectory.'/dump', 'tampered');
|
|
$this->postJson('/management/profile-install', $data)->assertConflict();
|
|
$this->postJson('/management/profile-install', $this->data())->assertUnprocessable();
|
|
$this->readinessFixture();
|
|
DB::table('migrations')->where('migration', '2026_10_09_030000_extend_opportunity_resources')->delete();
|
|
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('dependencies.resources_030000', false);
|
|
$this->postJson('/management/profile-install', $this->data())->assertUnprocessable();
|
|
Schema::table('users', fn ($t) => $t->unsignedBigInteger('portal_access_revision')->default(0));
|
|
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('consistent', false);
|
|
$this->postJson('/management/profile-install', $this->data())->assertConflict();
|
|
}
|
|
|
|
public function test_failed_audit_rolls_back_schema_and_ledger_and_can_retry(): void
|
|
{
|
|
$actor = $this->manager();
|
|
$this->emptySchema();
|
|
$this->login($actor);
|
|
$this->readinessFixture();
|
|
DB::statement("ALTER TABLE audit_events ADD CONSTRAINT qa_reject_profile_install CHECK (source_system <> 'portal-profile-installation')");
|
|
$this->postJson('/management/profile-install', $this->data())->assertStatus(503);
|
|
$this->assertFalse(Schema::hasTable('portal_role_assignments'));
|
|
$this->assertFalse(Schema::hasColumn('users', 'portal_access_revision'));
|
|
$this->assertFalse(DB::table('migrations')->where('migration', PortalProfileInstallation::MIGRATION)->exists());
|
|
DB::statement('ALTER TABLE audit_events DROP CONSTRAINT qa_reject_profile_install');
|
|
$this->postJson('/management/profile-install', $this->data())->assertOk()->assertJsonPath('changed', true);
|
|
}
|
|
|
|
public function test_bootstrap_is_explicit_independent_separate_and_cannot_replace_existing_pair(): void
|
|
{
|
|
$actor = $this->manager();
|
|
$first = $this->actor();
|
|
$second = $this->actor();
|
|
$this->login($actor, false);
|
|
$this->postJson('/management/profile-install', $this->data())->assertForbidden();
|
|
$this->login($actor);
|
|
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $first->id, 'second_user_id' => $second->id])->assertForbidden();
|
|
$pending = $this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $actor->id, 'second_user_id' => $second->id])->assertOk()->assertJsonPath('initialized', false)->assertJsonPath('privileges_granted', false)->json();
|
|
$this->assertSame(0, DB::table('portal_superuser_pair')->count());
|
|
$url = '/management/profile-install/bootstrap/'.$pending['approval_id'];
|
|
$view = $this->getJson($url)->assertOk()->json();
|
|
$this->postJson($url.'/confirm', [...$this->data(), 'hash' => $view['hash']])->assertForbidden();
|
|
$this->login($second);
|
|
$view = $this->getJson($url)->assertOk()->json();
|
|
$this->postJson($url.'/confirm', ['hash' => $view['hash'], 'confirm' => true, 'idempotency_key' => (string) Str::uuid(), 'reason' => 'Synthetic independent QA confirmation'])->assertOk()->assertJsonPath('holders', [$actor->id, $second->id]);
|
|
$this->login($first);
|
|
$this->getJson('/management/profile-install')->assertForbidden();
|
|
$this->login($actor);
|
|
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('bootstrap.initialized', true);
|
|
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $actor->id, 'second_user_id' => $first->id])->assertConflict();
|
|
$this->assertSame(1, DB::table('portal_superuser_pair')->count());
|
|
$this->assertSame(0, DB::table('portal_role_assignments')->count());
|
|
$this->assertFalse(config('portal_profiles.bootstrap_authorized'));
|
|
}
|
|
|
|
public function test_auditor_restriction_and_history_prevent_privilege_or_destructive_rollback(): void
|
|
{
|
|
$actor = $this->manager();
|
|
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->actor(), [
|
|
'user_id' => $actor->id, 'role' => 'auditor', 'opportunity_types' => [], 'source' => 'unit-synthetic-fixture']);
|
|
$this->login($actor);
|
|
$this->getJson('/management/profile-install')->assertForbidden();
|
|
$this->postJson('/management/profile-install', ['hash' => str_repeat('a', 64), 'confirm' => true, 'idempotency_key' => (string) Str::uuid(), 'reason' => 'Instalação sintética vedada ao Auditor'])->assertForbidden();
|
|
try {
|
|
(require database_path('migrations/'.PortalProfileInstallation::MIGRATION.'.php'))->down();
|
|
$this->fail('Used authorization schema must not be dropped.');
|
|
} catch (\RuntimeException $e) {
|
|
$this->assertStringContainsString('Preserve authorization history', $e->getMessage());
|
|
}
|
|
$this->assertSame(1, DB::table('portal_role_assignments')->count());
|
|
$this->assertTrue(Schema::hasColumn('users', 'portal_access_revision'));
|
|
}
|
|
|
|
public function test_rollback_preserves_context_even_without_author_or_grants(): void
|
|
{
|
|
DB::table('opportunities')->insert(['reference' => 'QA-rollback-context', 'slug' => 'qa-rollback-context', 'workflow_context' => '{"history":["synthetic-preserve"]}']);
|
|
try {
|
|
(require database_path('migrations/'.PortalProfileInstallation::MIGRATION.'.php'))->down();
|
|
$this->fail('Context must survive rollback even without an owner.');
|
|
} catch (\RuntimeException $e) {
|
|
$this->assertSame('Preserve scoped opportunities', $e->getMessage());
|
|
}
|
|
$this->assertTrue(Schema::hasColumn('opportunities', 'workflow_context'));
|
|
$this->assertSame(1, DB::table('opportunities')->whereNotNull('workflow_context')->count());
|
|
}
|
|
|
|
public function test_bootstrap_rejects_unreadable_mfa_and_auditor_candidate_without_creating_pair(): void
|
|
{
|
|
$manager = $this->manager();
|
|
$first = $this->actor();
|
|
$second = $this->actor();
|
|
$first->forceFill(['two_factor_secret' => 'synthetic-invalid-ciphertext'])->save();
|
|
config(['portal.managers' => [$first->email]]);
|
|
$this->login($first);
|
|
$this->postJson('/management/profile-install/bootstrap', [...$this->data(), 'first_user_id' => $first->id, 'second_user_id' => $second->id])->assertUnprocessable();
|
|
$this->assertSame(0, DB::table('portal_superuser_pair')->count());
|
|
$first->forceFill(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP')])->save();
|
|
$input = [...$this->data(), 'first_user_id' => $first->id, 'second_user_id' => $second->id];
|
|
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $manager, [
|
|
'user_id' => $first->id, 'role' => 'auditor', 'opportunity_types' => [], 'source' => 'unit-synthetic-fixture']);
|
|
$this->postJson('/management/profile-install/bootstrap', $input)->assertForbidden();
|
|
$this->assertSame(0, DB::table('portal_superuser_pair')->count());
|
|
$this->assertFalse(config('portal_profiles.bootstrap_authorized'));
|
|
}
|
|
|
|
public function test_partial_schema_cannot_disable_identity_write_controls_but_recovery_is_accessible(): void
|
|
{
|
|
$manager = $this->manager();
|
|
$before = $manager->two_factor_secret;
|
|
$this->login($manager);
|
|
config(['portal.profile_access_enabled' => true]);
|
|
Schema::table('users', fn ($t) => $t->dropColumn('portal_access_revision'));
|
|
$this->deleteJson('/user/two-factor-authentication')->assertStatus(503);
|
|
$this->deleteJson('/settings/profile', ['password' => 'not-a-credential'])->assertStatus(503);
|
|
$this->assertSame($before, $manager->fresh()->two_factor_secret);
|
|
$this->getJson('/management/profile-install')->assertOk()->assertJsonPath('consistent', false);
|
|
$this->postJson('/management/profile-install', $this->data())->assertConflict();
|
|
$this->post('/logout')->assertRedirect('/');
|
|
$this->assertGuest();
|
|
}
|
|
}
|