127 lines
7.7 KiB
PHP
127 lines
7.7 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use App\Services\ExistingBusinessDrafts;
|
|
use App\Services\Mne\BusinessRepository;
|
|
use App\Services\Mne\CompatibilityBackfill;
|
|
use App\Services\Mne\IdentityResolver;
|
|
use App\Services\Mne\SectorResolver;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
require __DIR__.'/../vendor/autoload.php';
|
|
$app = require __DIR__.'/../bootstrap/app.php';
|
|
$app->make(Kernel::class)->bootstrap();
|
|
$db = DB::connection();
|
|
if (! $app->environment('testing') || $db->getDatabaseName() !== 'portal_services_test' || (string) $db->getConfig('port') !== '55441') {
|
|
throw new RuntimeException('Isolated QA required');
|
|
}
|
|
if ($db->selectOne("SELECT count(*) AS n FROM pg_namespace WHERE nspname='mne'")->n) {
|
|
throw new RuntimeException('QA schema mne must be absent');
|
|
}
|
|
$run = function (string $file) use ($db): void {
|
|
$sql = preg_replace('/^BEGIN;\s*$/m', '', file_get_contents(base_path('migrations/'.$file)));
|
|
$db->unprepared(preg_replace('/COMMIT;\s*$/', '', $sql));
|
|
};
|
|
$check = function (bool $condition, string $message): void {
|
|
if (! $condition) {
|
|
throw new RuntimeException($message);
|
|
}
|
|
};
|
|
$reject = function (callable $operation, string $state) use ($db, $check): void {
|
|
$db->statement('SAVEPOINT rejection');
|
|
$rejected = false;
|
|
try {
|
|
$operation();
|
|
} catch (QueryException $e) {
|
|
$rejected = $e->getCode() === $state;
|
|
}
|
|
$db->statement('ROLLBACK TO SAVEPOINT rejection');
|
|
$check($rejected, 'Expected database rejection '.$state);
|
|
};
|
|
$db->beginTransaction();
|
|
try {
|
|
foreach (['001_mne_schema.sql', '002_mne_existing_territories.sql', '003_mne_integrity.sql', '004_mne_compatibility.sql'] as $file) {
|
|
$run($file);
|
|
}
|
|
$run('004_mne_compatibility.sql');
|
|
$run('004_mne_compatibility_down.sql');
|
|
$run('004_mne_compatibility.sql');
|
|
$user = User::factory()->create();
|
|
config(['existing-business.enabled' => true]);
|
|
$business = app(ExistingBusinessDrafts::class)->save($user, [
|
|
'modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'Synthetic compatibility test',
|
|
'payload' => ['title' => 'Synthetic private business'],
|
|
]);
|
|
$stranger = User::factory()->create();
|
|
$loaded = app(BusinessRepository::class)->find($business->id);
|
|
$check(Gate::forUser($user)->allows('view', $loaded), 'Owner lost access');
|
|
$check(Gate::forUser($stranger)->denies('view', $loaded), 'Unauthorized access');
|
|
$sector = $db->table('public.sectors')->insertGetId(['name' => 'Synthetic compatibility', 'slug' => 'synthetic-compatibility', 'is_active' => true]);
|
|
$backfill = app(CompatibilityBackfill::class);
|
|
$dryStatus = Artisan::call('mne:backfill-compatibility', ['--batch' => 2, '--dry-run' => true]);
|
|
$check($dryStatus === 0, 'Dry run failed');
|
|
foreach (['identity_links', 'sector_links', 'business_links', 'compatibility_checkpoints', 'compatibility_rejections'] as $table) {
|
|
$check($db->table('mne.'.$table)->count() === 0, 'Dry run persisted data: '.$table);
|
|
}
|
|
$first = $backfill->run(2);
|
|
$links = $db->table('mne.identity_links')->orderBy('user_id')->get()->toJson();
|
|
$second = $backfill->run(2);
|
|
$check($first === $second, 'Backfill counts changed');
|
|
$check($links === $db->table('mne.identity_links')->orderBy('user_id')->get()->toJson(), 'Backfill not idempotent');
|
|
$check(app(SectorResolver::class)->resolve($sector) !== null, 'Sector not resolved');
|
|
$check(app(SectorResolver::class)->resolve(2147483647) === null, 'Unknown sector invented');
|
|
$row = (array) $db->table('mne.identity_links')->where('user_id', $user->id)->first();
|
|
unset($row['id']);
|
|
$reject(fn () => $db->table('mne.identity_links')->insert($row), '23505');
|
|
$row['user_id'] = 9223372036854775807;
|
|
$reject(fn () => $db->table('mne.identity_links')->insert($row), '23503');
|
|
$reject(fn () => $db->table('mne.identity_links')->where('user_id', $user->id)->update(['status' => 'active']), '23514');
|
|
$party = $db->selectOne("INSERT INTO mne.parties(party_type,display_name) VALUES ('person','Synthetic party') RETURNING id")->id;
|
|
$db->table('mne.identity_links')->where('user_id', $user->id)->update(['party_id' => $party, 'status' => 'active']);
|
|
$check(app(IdentityResolver::class)->resolve($user->id) === $party, 'Identity link not resolved');
|
|
$reject(fn () => $db->table('mne.identity_links')->where('user_id', $stranger->id)->update(['party_id' => $party]), '23505');
|
|
$db->table('mne.identity_links')->where('user_id', $user->id)->update(['status' => 'revoked']);
|
|
$check(app(IdentityResolver::class)->resolve($user->id) === null, 'Revoked identity resolved');
|
|
$reject(fn () => $run('004_mne_compatibility_down.sql'), 'P0001');
|
|
$check($db->table('mne.existing_businesses')->count() === 0, 'Backfill copied business data');
|
|
$check($db->table('mne.parties')->count() === 1, 'Backfill invented identities');
|
|
$db->statement('SAVEPOINT schema_drift');
|
|
$db->statement('ALTER TABLE mne.business_links ADD COLUMN unexpected text');
|
|
$reject(fn () => $run('004_mne_compatibility.sql'), 'P0001');
|
|
$db->statement('ROLLBACK TO SAVEPOINT schema_drift');
|
|
$db->table('public.sectors')->where('id', $sector)->update(['slug' => 'changed-compatibility']);
|
|
$check(app(SectorResolver::class)->resolve($sector) === null, 'Stale code resolved');
|
|
$backfill->run(2);
|
|
$check($db->table('mne.sector_links')->where('sector_id', $sector)->value('status') === 'pending', 'Changed source not invalidated');
|
|
$run('005_mne_compatibility_guards.sql');
|
|
$run('005_mne_compatibility_guards.sql');
|
|
$reject(fn () => $db->table('mne.compatibility_controls')->where('capability', 'canonical_read')
|
|
->update(['enabled' => true, 'evidence_reference' => 'Synthetic activation attempt']), '23514');
|
|
$reject(fn () => $db->table('mne.compatibility_controls')->where('capability', 'canonical_read')->update(['percentage' => 1]), '23514');
|
|
$run('005_mne_compatibility_guards_down.sql');
|
|
$run('005_mne_compatibility_guards.sql');
|
|
// Transactional test role: cannot own objects, create schemas, or disable protections.
|
|
$role = 'mne_runtime_test_'.getmypid();
|
|
$db->statement('CREATE ROLE '.$role.' NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS');
|
|
$db->statement('GRANT USAGE ON SCHEMA mne TO '.$role);
|
|
$db->statement('GRANT SELECT, INSERT ON mne.audit_events, mne.business_status_history, mne.document_versions, mne.negotiation_events TO '.$role);
|
|
$db->statement('SET LOCAL ROLE '.$role);
|
|
$check(! $db->selectOne('SELECT rolsuper FROM pg_roles WHERE rolname=current_user')->rolsuper, 'Runtime test is superuser');
|
|
$db->statement("INSERT INTO mne.audit_events(action,subject_type,subject_id,correlation_id) VALUES ('synthetic','test',gen_random_uuid(),gen_random_uuid())");
|
|
foreach (['audit_events', 'business_status_history', 'document_versions', 'negotiation_events'] as $table) {
|
|
foreach (["UPDATE mne.$table SET id=id", "DELETE FROM mne.$table", "TRUNCATE mne.$table"] as $sql) {
|
|
$reject(fn () => $db->statement($sql), '42501');
|
|
}
|
|
}
|
|
$reject(fn () => $db->statement('ALTER TABLE mne.audit_events DISABLE TRIGGER ALL'), '42501');
|
|
$reject(fn () => $db->statement('CREATE TABLE mne.forbidden_runtime_table(id integer)'), '42501');
|
|
$db->statement('RESET ROLE');
|
|
echo json_encode(['passed' => true, 'dry_run_rolled_back' => true, '005_repeated_and_reversed' => true, 'canonical_activation_rejected' => true, 'up_twice' => true, 'down_reapply' => true, 'backfill_replay' => true, 'uniqueness_fk_activation_down_guards' => true, 'counts' => $first]).PHP_EOL;
|
|
} finally {
|
|
$db->rollBack();
|
|
}
|