Files
portal/app/tests/Feature/Console/VerifyAuditChainsTest.php
T
Дмитрий d170c886bc feat(audit): hash-chain integrity validator — audit:verify-chains (hole #1)
Closes hole #1: log_hash written by trigger but never verified → tampering invisible.
audit:verify-chains (cron daily 04:00) recomputes SHA-256 chain for all 6 audit
tables via SQL on pgsql_supplier (prod-safe). Serialization reproduces trigger
exactly (ROW with log_hash=NULL::bytea). Break → incidents_log (high, dedup 24h)
+ AuditChainBreachMail to kdv1@bk.ru + non-zero exit. Tests 5/5, Console 19/19.
2026-05-23 10:27:55 +03:00

168 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Mail\AuditChainBreachMail;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
// ---------------------------------------------------------------------------
// Helper: ensure at least one active saas_admin_user row exists (FK for incidents_log)
// ---------------------------------------------------------------------------
function ensureAuditAdmin(): int
{
$id = DB::table('saas_admin_users')
->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);
});