where('is_active', true) ->whereNull('deleted_at') ->value('id'); if ($id !== null) { return (int) $id; } return (int) DB::table('saas_admin_users')->insertGetId([ 'email' => 'audit-cron@liderra.ru', 'full_name' => 'Audit Cron', 'password_hash' => '$2y$12$placeholder', 'role' => 'dev_oncall', 'is_active' => true, 'created_at' => now(), ]); } // --------------------------------------------------------------------------- // Helper: insert N rows into auth_log via the normal path (trigger fills log_hash). // Uses the 3rd constraint variant: actor_type='tenant_user', user_id=NULL, // saas_admin_user_id=NULL, email IS NOT NULL (login attempt with unknown email). // --------------------------------------------------------------------------- function insertAuthLogRows(int $n, ?int $tenantId = null): void { for ($i = 0; $i < $n; $i++) { DB::table('auth_log')->insert([ 'actor_type' => 'tenant_user', 'tenant_id' => $tenantId, 'email' => "test{$i}@example.com", 'event' => 'login_failed', 'ip_address' => '127.0.0.1', 'created_at' => now(), ]); } } // --------------------------------------------------------------------------- // Helper: ensure a tenant row exists, return its id. // --------------------------------------------------------------------------- function ensureTenant(int $seed): int { $existing = DB::table('tenants')->where('subdomain', "test-chain-{$seed}")->value('id'); if ($existing !== null) { return (int) $existing; } return (int) DB::table('tenants')->insertGetId([ 'organization_name' => "Test Chain {$seed}", 'subdomain' => "test-chain-{$seed}", 'contact_email' => "chain{$seed}@example.com", 'status' => 'active', 'created_at' => now(), 'updated_at' => now(), ]); } // --------------------------------------------------------------------------- // Helper: insert one row into tenant_operations_log under a specific tenant. // The trigger fills log_hash based on what it can SELECT (see notes below). // --------------------------------------------------------------------------- function insertTenantOpsRow(int $tenantId, string $event = 'project.created'): int { return (int) DB::table('tenant_operations_log')->insertGetId([ 'tenant_id' => $tenantId, 'entity_type' => 'project', 'entity_id' => 1, 'event' => $event, 'created_at' => now(), ]); } // --------------------------------------------------------------------------- // Helper: build per-tenant chained hashes in SQL (mirrors the validator logic). // // This computes what the per-scope validator expects: for each row in // tenant_operations_log, prev_hash = LAG(log_hash) OVER (PARTITION BY tenant_id // ORDER BY id). We use this to manually seed correctly-chained rows when we // need to bypass the trigger (which on dev/superuser chains globally). // // Returns array of ['id' => int, 'hash' => resource] sorted by id. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- beforeEach(function () { ensureAuditAdmin(); Mail::fake(); }); // =========================================================================== // TDD ANCHOR: clean chain must verify intact (serialization correctness gate) // =========================================================================== test('clean auth_log chain verifies intact', function () { insertAuthLogRows(3); $exitCode = Artisan::call('audit:verify-chains'); $out = Artisan::output(); if ($exitCode !== 0) { dump('OUTPUT:', $out); } expect($exitCode)->toBe(0); // No incident should be created for an intact chain $count = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('summary', 'like', '%chain%auth_log%') ->count(); expect($count)->toBe(0); // No email should be sent Mail::assertNothingSent(); }); test('empty tables are skipped gracefully (no false positive)', function () { // auth_log might have rows from other tests but other tables are empty on dev. // The command must not raise an error on empty tables. $this->artisan('audit:verify-chains')->assertSuccessful(); Mail::assertNothingSent(); }); test('multiple rows in auth_log all pass intact', function () { insertAuthLogRows(10); $this->artisan('audit:verify-chains')->assertSuccessful(); Mail::assertNothingSent(); }); // =========================================================================== // MULTI-TENANT REGRESSION — SUPERSEDED by ADR-021. // // The former test here ('per-tenant chained tenant_operations_log validates // intact') manually seeded PER-TENANT-chained hashes and asserted the per-tenant // validator accepted them. Its premise — that the trigger produces a per-tenant // chain on prod — was empirically disproven 2026-07-09: the trigger produces a // GLOBAL chain for all tables (incl. balance_transactions). ADR-021 makes global // the canonical scope. The real, prod-accurate scenario (mixed-tenant rows // chained BY THE TRIGGER = global) is covered by // 'cross-tenant rows chained by the trigger (global) verify intact — ADR-021'. // =========================================================================== // =========================================================================== // Tampering detection // =========================================================================== test('tampered auth_log row raises incident and sends email', function () { insertAuthLogRows(3); // Sanity: intact before tampering $this->artisan('audit:verify-chains')->assertSuccessful(); Mail::assertNothingSent(); // Tamper: disable triggers, mutate the first row's ip_address, re-enable. // DISABLE TRIGGER USER disables all user-defined triggers (audit_block_mutation // is BEFORE UPDATE, so without disabling it the UPDATE would be blocked). DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER'); DB::table('auth_log') ->orderBy('id') ->limit(1) ->update(['ip_address' => '6.6.6.6']); DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER'); // Now the command must detect the breach $this->artisan('audit:verify-chains')->assertFailed(); $incident = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('type', 'other') ->where('severity', 'high') ->where('summary', 'like', '%chain%auth_log%') ->first(); expect($incident)->not->toBeNull(); expect($incident->summary)->toContain('auth_log'); Mail::assertSent(AuditChainBreachMail::class, function ($mail) { return $mail->tableName === 'auth_log'; }); }); test('incident dedup: same table breach does not create duplicate within 24h', function () { insertAuthLogRows(3); // First tamper DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER'); DB::table('auth_log') ->orderBy('id') ->limit(1) ->update(['ip_address' => '5.5.5.5']); DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER'); $this->artisan('audit:verify-chains')->assertFailed(); $countAfterFirst = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('summary', 'like', '%chain%auth_log%') ->count(); expect($countAfterFirst)->toBe(1); // Second run (same ongoing breach) must NOT create a second incident (dedup) $this->artisan('audit:verify-chains')->assertFailed(); $countAfterSecond = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('summary', 'like', '%chain%auth_log%') ->count(); expect($countAfterSecond)->toBe(1); }); // =========================================================================== // PARTITION-AWARE: breach in one partition must record incident with // partition_name; other partitions must remain intact (no false positive). // =========================================================================== test('breach in one partition is detected; other partitions reported intact', function () { // Insert 3 rows into auth_log — trigger assigns log_hash correctly. // After the migration, auth_log is partitioned by month; all test rows // go into the current month's partition (e.g. auth_log_y2026_m05). insertAuthLogRows(3); // Sanity: chain intact before tampering. $this->artisan('audit:verify-chains')->assertSuccessful(); Mail::assertNothingSent(); // Determine which partition the inserted rows landed in, so we can assert // the incident summary references that partition, not just "auth_log". // // Rows are inserted with created_at = now(), so they land in the partition // whose range covers the current month. We derive the expected name the // same way buildPartitionDDL() does: {table}_y{YYYY}_m{MM}. // // Fallback: if auth_log has no children (migration not applied), we expect // the incident to reference 'auth_log' itself (command's fallback path). $partitionCount = DB::selectOne( "SELECT COUNT(*) AS cnt FROM pg_inherits i JOIN pg_class p ON p.oid = i.inhparent WHERE p.relname = 'auth_log'", )->cnt ?? 0; $expectedPartition = $partitionCount > 0 ? sprintf('auth_log_y%s_m%s', now()->format('Y'), now()->format('m')) : 'auth_log'; // Tamper the first row in auth_log (which lands in $expectedPartition). DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER'); DB::table('auth_log') ->orderBy('id') ->limit(1) ->update(['ip_address' => '7.7.7.7']); DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER'); // Command must fail and reference the partition in the incident summary. $this->artisan('audit:verify-chains')->assertFailed(); $incident = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('type', 'other') ->where('severity', 'high') ->where('summary', 'like', '%chain%auth_log%') ->orderByDesc('id') ->first(); expect($incident)->not->toBeNull('An incident must be recorded on chain breach'); // The incident summary must mention the specific partition (or the table if not yet partitioned). expect($incident->summary)->toContain($expectedPartition); // Email must be sent referencing the correct partition. Mail::assertSent(AuditChainBreachMail::class, function ($mail) use ($expectedPartition) { // partitionName is the 4th constructor arg (nullable); falls back to tableName. $actualPartition = $mail->partitionName ?? $mail->tableName; return $actualPartition === $expectedPartition && $mail->tableName === 'auth_log'; }); }); // =========================================================================== // EXIT CODE: breach must always return FAILURE regardless of incident write // =========================================================================== // =========================================================================== // AUTO-RESOLVE: when a table's chain is fully intact, a previously-open // chain incident for that table must be auto-resolved (stale-incident cleanup). // Driven by FN-AUDIT (acceptance run 22.06): transient breaches on test-tenant // rows left incidents open after teardown deleted the offending rows. // =========================================================================== test('intact table auto-resolves a previously-open chain incident', function () { $adminId = ensureAuditAdmin(); insertAuthLogRows(3); // auth_log chain intact // Seed a stale open incident for auth_log, exactly as VerifyAuditChains writes it. $incidentId = DB::connection('pgsql_supplier')->table('incidents_log')->insertGetId([ 'type' => 'other', 'severity' => 'high', 'summary' => 'Автоматически: разрыв hash-chain в партиции auth_log_y2026_m06 (таблица auth_log). Первый сломанный id=1, всего несовпадений=1.', 'root_cause' => null, 'started_at' => now()->subDay(), 'detected_at' => now()->subDay(), 'resolved_at' => null, 'created_by_admin_id' => $adminId, 'created_at' => now()->subDay(), 'updated_at' => now()->subDay(), ]); Artisan::call('audit:verify-chains'); $incident = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('id', $incidentId) ->first(); expect($incident->resolved_at)->not->toBeNull(); expect((string) $incident->root_cause)->toContain('auth_log'); }); test('breached table does not auto-resolve its open chain incident', function () { $adminId = ensureAuditAdmin(); insertAuthLogRows(3); // Seed an open incident for auth_log. $incidentId = DB::connection('pgsql_supplier')->table('incidents_log')->insertGetId([ 'type' => 'other', 'severity' => 'high', 'summary' => 'Автоматически: разрыв hash-chain в партиции auth_log_y2026_m06 (таблица auth_log).', 'root_cause' => null, 'started_at' => now()->subDay(), 'detected_at' => now()->subDay(), 'resolved_at' => null, 'created_by_admin_id' => $adminId, 'created_at' => now()->subDay(), 'updated_at' => now()->subDay(), ]); // Tamper auth_log so its chain is broken at run time. DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER'); DB::table('auth_log') ->orderBy('id') ->limit(1) ->update(['ip_address' => '4.4.4.4']); DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER'); Artisan::call('audit:verify-chains'); $incident = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('id', $incidentId) ->first(); expect($incident->resolved_at)->toBeNull(); }); test('exit code is FAILURE on breach even when no active admin exists for incident FK', function () { // Remove all active admins so recordIncident cannot write the FK row DB::table('saas_admin_users')->update(['is_active' => false]); insertAuthLogRows(2); // Tamper DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER'); DB::table('auth_log') ->orderBy('id') ->limit(1) ->update(['ip_address' => '9.9.9.9']); DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER'); // Must FAIL even though the incident row cannot be written (no active admin) $this->artisan('audit:verify-chains')->assertFailed(); // No incident row written (no active admin) $count = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('summary', 'like', '%chain%auth_log%') ->count(); expect($count)->toBe(0); // But email alert must still be sent Mail::assertSent(AuditChainBreachMail::class); }); // =========================================================================== // ADR-021: audit hash-chain canonical is GLOBAL-within-partition for all tables. // The trigger produces a global chain (verified on prod 2026-07-09 for all 4 // formerly-"per-tenant" tables incl. balance_transactions; also true on // dev/superuser). Mixed-tenant rows inserted VIA THE TRIGGER must therefore // verify intact — the old per-tenant validator (ADR-018) false-positived at // every tenant boundary. Supersedes the per-tenant regression test above. // =========================================================================== test('cross-tenant rows chained by the trigger (global) verify intact — ADR-021', function () { $t1 = ensureTenant(1); $t2 = ensureTenant(2); // Insert via the normal path (trigger fills log_hash). On dev/superuser the // trigger chains GLOBALLY — exactly what prod produces. Interleave tenants so // the chain crosses tenant boundaries (id2:t2 after id1:t1, etc.). insertTenantOpsRow($t1, 'project.created'); insertTenantOpsRow($t2, 'api_key.regenerated'); insertTenantOpsRow($t1, 'project.updated'); insertTenantOpsRow($t2, 'project.created'); insertTenantOpsRow($t1, 'project.deleted'); // Under global scope (ADR-021) this is a valid intact chain → no breach. $this->artisan('audit:verify-chains')->assertSuccessful(); $count = DB::connection('pgsql_supplier') ->table('incidents_log') ->where('summary', 'like', '%chain%tenant_operations_log%') ->count(); expect($count)->toBe(0); Mail::assertNothingSent(); });