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(), ]); } } // --------------------------------------------------------------------------- // 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); $this->artisan('audit:verify-chains')->assertSuccessful(); // 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(); }); // =========================================================================== // 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); });