Files
Investidor-V2/tests/Feature/SupportProfileInstallationTest.php

252 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityTechnicalSupport;
use App\Services\PortalProfileAdministration;
use App\Services\PortalProfileInstallation;
use App\Services\TechnicalSupportProfileSchema;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Tests\TestCase;
final class SupportProfileInstallationTest extends TestCase
{
use RefreshDatabase;
private const URL = '/management/profile-install/technical-support';
private User $admin;
private User $second;
private User $staff;
private string $directory;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
$this->admin = $this->actor();
$this->second = $this->actor();
$this->staff = $this->actor();
DB::table('portal_superuser_pair')->insert(['id' => 1, 'first_user_id' => $this->admin->id, 'second_user_id' => $this->second->id, 'approved_by' => $this->second->id, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
$this->directory = storage_path('framework/testing/support-profile-install-'.Str::uuid());
mkdir($this->directory, 0700, true);
config(['portal_profiles.proof_directory' => $this->directory, 'portal.profile_access_enabled' => true, 'portal.managers' => [$this->admin->email, $this->staff->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->login($this->admin);
}
protected function tearDown(): void
{
foreach (glob($this->directory.'/*') ?: [] as $file) {
unlink($file);
}rmdir($this->directory);
parent::tearDown();
}
private function actor(): User
{
return User::factory()->create(['two_factor_secret' => Crypt::encryptString('JBSWY3DPEHPK3PXP'), 'two_factor_confirmed_at' => now()]);
}
private function login(User $actor, bool $mfa = true): static
{
return $this->actingAs($actor)->withSession(['portal.mfa_actor' => $mfa ? $actor->id : null, 'portal.mfa_at' => $mfa ? time() : 0, 'portal.access_revision' => (int) $actor->fresh()->portal_access_revision]);
}
private function beforeSupport(bool $keepLedger = false): void
{
(require database_path('migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php'))->down();
if (! $keepLedger) {
DB::table('migrations')->where('migration', TechnicalSupportProfileSchema::MIGRATION)->delete();
}
}
/** Synthetic files validate the existing readiness contract; these are not real deployment backups. @return array<string,mixed> */
private function proof(): array
{
file_put_contents($this->directory.'/dump', 'SYNTHETIC contract fixture, not a database dump.');
file_put_contents($this->directory.'/files', 'SYNTHETIC contract fixture, not a private-file archive.');
$proof = ['verified_at' => gmdate('c'), 'target' => DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'), 'code_hash' => app(PortalProfileInstallation::class)->fingerprint(), 'restore_verified' => true, 'files_verified' => true, 'compatibility_verified' => true, 'qa_passed' => true, 'dump_path' => $this->directory.'/dump', 'dump_sha256' => hash_file('sha256', $this->directory.'/dump'), 'files_path' => $this->directory.'/files', 'files_sha256' => hash_file('sha256', $this->directory.'/files')];
$this->writeProof($proof);
return $proof;
}
/** @param array<string,mixed> $proof */
private function writeProof(array $proof): void
{
file_put_contents($this->directory.'/readiness.json', json_encode($proof, JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
private function input(): array
{
return ['hash' => $this->getJson(self::URL)->assertOk()->json('hash'), 'confirm' => true, 'reason' => 'SYNTHETIC scoped support profile upgrade in isolated QA.', 'idempotency_key' => (string) Str::uuid()];
}
public function test_authenticated_upgrade_preserves_accounts_existing_authority_and_business_data_without_granting_support(): void
{
$this->beforeSupport();
$organization = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $organization, 'name' => 'SYNTHETIC preserved organization', 'organization_type' => 'private']);
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->admin, ['user_id' => $this->staff->id, 'role' => 'promoter', 'organization_id' => $organization, 'opportunity_types' => ['investment_project']]);
$id = (string) Str::uuid();
$opportunity = new Opportunity(['reference' => $id, 'slug' => $id, 'title' => 'PRIVATE SYNTHETIC PRESERVED INVESTMENT', 'status' => 'draft']);
$opportunity->forceFill(['owner_user_id' => $this->staff->id, 'organization_id' => $organization, 'opportunity_category' => 'investment_project', 'management_version' => 3, 'workflow_context' => ['working_state' => 'draft', 'history' => ['SYNTHETIC previous version']]])->save();
$before = [];
foreach (['users', 'organizations', 'portal_role_assignments', 'portal_superuser_pair', 'opportunities', 'tasks'] as $table) {
$before[$table] = DB::table($table)->orderBy('id')->get()->toJson();
}
$this->proof();
$this->getJson(self::URL)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('ready', true)->assertJsonPath('action', 'install')->assertJsonPath('schema.state', 'base');
$input = $this->input();
$result = $this->postJson(self::URL, $input)->assertOk()->assertJsonPath('installed', true)->assertJsonPath('changed', true)->assertJsonPath('privileges_granted', false)->assertJsonPath('mode', 'install')->json();
$this->postJson(self::URL, $input)->assertOk()->assertExactJson($result);
$this->postJson(self::URL, [...$input, 'reason' => 'SYNTHETIC changed upgrade purpose'])->assertConflict();
foreach ($before as $table => $snapshot) {
$this->assertSame($snapshot, DB::table($table)->orderBy('id')->get()->toJson());
}
$this->assertTrue(app(OpportunityTechnicalSupport::class)->installed());
$this->assertFalse(app(OpportunityTechnicalSupport::class)->allows($this->staff));
$this->assertFalse(config('portal_profiles.authenticated_installation'));
$this->assertSame(1, DB::table('migrations')->where('migration', TechnicalSupportProfileSchema::MIGRATION)->count());
$this->assertSame(1, DB::table('audit_events')->where('action', 'technical-support-install')->count());
}
public function test_already_installed_update_is_noop_and_receipts_are_visible_only_to_their_actor(): void
{
$input = $this->input();
$this->postJson(self::URL, $input)->assertOk()->assertJsonPath('changed', false)->assertJsonPath('mode', 'already_installed');
$this->getJson(self::URL)->assertOk()->assertJsonCount(1, 'commands')->assertJsonPath('commands.0.key', $input['idempotency_key'])->assertJsonMissingPath('commands.0.request_hash');
$this->login($this->second)->getJson(self::URL)->assertOk()->assertJsonCount(0, 'commands');
$this->login($this->admin, false)->postJson(self::URL, $input)->assertForbidden();
DB::table('portal_superuser_pair')->where('id', 1)->update(['first_user_id' => $this->staff->id]);
$this->login($this->admin)->getJson(self::URL)->assertForbidden();
$this->postJson(self::URL, $input)->assertForbidden();
}
public function test_final_audit_failure_rolls_back_role_constraint_and_migration_ledger_and_allows_explicit_retry(): void
{
$this->beforeSupport();
$this->proof();
$input = $this->input();
DB::statement("ALTER TABLE audit_events ADD CONSTRAINT qa_reject_support_install CHECK (source_system <> 'portal-profile-installation')");
$this->postJson(self::URL, $input)->assertStatus(503);
$state = app(TechnicalSupportProfileSchema::class)->inspect();
$this->assertSame('base', $state['state']);
$this->assertFalse($state['ledger']);
$this->assertFalse(config('portal_profiles.authenticated_installation'));
DB::statement('ALTER TABLE audit_events DROP CONSTRAINT qa_reject_support_install');
$this->postJson(self::URL, $input)->assertOk()->assertJsonPath('changed', true);
}
public function test_readiness_rejects_false_flags_malformed_values_expiry_future_dates_wrong_target_and_tampered_files(): void
{
$this->beforeSupport();
$valid = $this->proof();
$target = get_object_vars($valid['target']);
$variants = [['restore_verified' => 'false'], ['qa_passed' => 1], ['verified_at' => []], ['verified_at' => gmdate('c', time() + 3600)], ['verified_at' => gmdate('c', time() - 172800)], ['dump_path' => []], ['target' => 'invalid'], ['target' => [...$target, 'port' => 5432]], ['code_hash' => str_repeat('0', 64)]];
foreach ($variants as $variant) {
$this->writeProof([...$valid, ...$variant]);
$this->getJson(self::URL)->assertOk()->assertJsonPath('ready', false);
$this->postJson(self::URL, $this->input())->assertUnprocessable();
}
$this->writeProof($valid);
file_put_contents($this->directory.'/files', 'SYNTHETIC tampering');
$this->getJson(self::URL)->assertOk()->assertJsonPath('ready', false);
$this->postJson(self::URL, $this->input())->assertUnprocessable();
$this->assertFalse(app(OpportunityTechnicalSupport::class)->installed());
}
public function test_a_changed_valid_proof_invalidates_the_old_reviewed_plan_and_normalized_retry_keeps_the_same_receipt(): void
{
$this->beforeSupport();
$proof = $this->proof();
$stale = $this->input();
$this->writeProof([...$proof, 'snapshot_reference' => 'SYNTHETIC newer verified snapshot']);
$this->getJson(self::URL)->assertJsonPath('ready', true);
$this->postJson(self::URL, $stale)->assertConflict();
$input = $this->input();
$this->postJson(self::URL, [...$input, 'idempotency_key' => strtoupper($input['idempotency_key']), 'confirm' => 'yes', 'reason' => ' '.$input['reason'].' '])->assertOk();
$this->postJson(self::URL, $input)->assertOk();
$this->assertSame(1, DB::table('audit_events')->where('action', 'technical-support-install')->count());
}
public function test_known_base_constraint_with_registered_migration_has_a_scoped_authenticated_repair(): void
{
$ledger = DB::table('migrations')->where('migration', TechnicalSupportProfileSchema::MIGRATION)->first();
$this->beforeSupport(true);
$this->assertFalse(app(OpportunityTechnicalSupport::class)->installed());
$this->getJson(self::URL)->assertOk()->assertJsonPath('consistent', false)->assertJsonPath('repairable', true)->assertJsonPath('ready', false);
$this->postJson(self::URL, $this->input())->assertUnprocessable();
$this->proof();
$this->postJson(self::URL, $this->input())->assertOk()->assertJsonPath('mode', 'repair_known_base')->assertJsonPath('changed', true);
$this->assertEquals($ledger, DB::table('migrations')->where('migration', TechnicalSupportProfileSchema::MIGRATION)->first());
$this->assertTrue(app(OpportunityTechnicalSupport::class)->installed());
$this->assertSame(0, DB::table('portal_role_assignments')->count());
}
public function test_unknown_or_weakened_constraints_are_not_overwritten_or_reported_as_installed(): void
{
$this->proof();
DB::statement("ALTER TABLE portal_role_assignments DROP CONSTRAINT portal_role_name, ADD CONSTRAINT portal_role_name CHECK (role IN ('promoter','validator','publisher','auditor','technical_support') OR TRUE)");
$fingerprint = app(TechnicalSupportProfileSchema::class)->inspect()['fingerprint'];
$this->getJson(self::URL)->assertOk()->assertJsonPath('schema.state', 'unrecognised')->assertJsonPath('installed', false)->assertJsonPath('repairable', false);
$this->postJson(self::URL, $this->input())->assertConflict();
$this->assertSame($fingerprint, app(TechnicalSupportProfileSchema::class)->inspect()['fingerprint']);
DB::statement('ALTER TABLE portal_role_assignments DROP CONSTRAINT portal_role_name');
$this->postJson(self::URL, $this->input())->assertConflict();
}
public function test_missing_ledger_for_existing_support_schema_does_not_implicitly_adopt_authority(): void
{
DB::table('migrations')->where('migration', TechnicalSupportProfileSchema::MIGRATION)->delete();
$this->proof();
$this->getJson(self::URL)->assertOk()->assertJsonPath('schema.state', 'support')->assertJsonPath('installed', false)->assertJsonPath('repairable', false);
$this->postJson(self::URL, $this->input())->assertConflict();
$this->assertFalse(DB::table('migrations')->where('migration', TechnicalSupportProfileSchema::MIGRATION)->exists());
}
public function test_support_role_and_legacy_allowlist_do_not_grant_installation_authority_and_auditor_denial_prevails(): void
{
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->admin, ['user_id' => $this->staff->id, 'role' => 'technical_support', 'opportunity_types' => ['investment_project']]);
$this->login($this->staff)->getJson(self::URL)->assertForbidden();
$this->postJson(self::URL, ['hash' => str_repeat('a', 64), 'confirm' => true, 'reason' => 'SYNTHETIC forbidden installation', 'idempotency_key' => (string) Str::uuid()])->assertForbidden();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->second, ['user_id' => $this->admin->id, 'role' => 'auditor', 'opportunity_types' => []]);
$this->login($this->admin)->getJson(self::URL)->assertForbidden();
$this->postJson(self::URL, [])->assertForbidden();
}
public function test_upgrade_keeps_existing_dependency_and_historical_support_downgrade_controls(): void
{
$this->beforeSupport();
$this->proof();
DB::table('migrations')->where('migration', '2026_10_09_030000_extend_opportunity_resources')->delete();
$this->getJson(self::URL)->assertJsonPath('ready', false)->assertJsonPath('dependencies.resources_030000', false);
$this->postJson(self::URL, $this->input())->assertUnprocessable();
DB::table('migrations')->insert(['migration' => '2026_10_09_030000_extend_opportunity_resources', 'batch' => 1]);
$this->postJson(self::URL, $this->input())->assertOk();
$assignment = app(PortalProfileAdministration::class)->grant($this->admin, ['user_id' => $this->staff->id, 'role' => 'technical_support', 'opportunity_types' => ['investment_project'], 'reason' => 'SYNTHETIC explicit independent support assignment']);
app(PortalProfileAdministration::class)->revoke($this->admin, $assignment, 'SYNTHETIC explicit removal; preserve the historical assignment');
try {
(require database_path('migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php'))->down();
$this->fail('Historical support assignment must survive downgrade.');
} catch (\LogicException $error) {
$this->assertStringContainsString('Preserve existing and historical', $error->getMessage());
}
$this->assertTrue(app(OpportunityTechnicalSupport::class)->installed());
$this->assertSame(1, DB::table('portal_role_assignments')->count());
}
}