Files
portal/app/tests/Feature/Audit/AuditRebuildChainTest.php
T

325 lines
15 KiB
PHP
Raw Normal View History

<?php
// Tests for audit:rebuild-chain command (Task 3).
declare(strict_types=1);
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Tests for audit:rebuild-chain command.
*
* Verifies that:
* 1. The command recomputes log_hash values using the same formula as audit_chain_hash():
* digest(COALESCE(prev_hash, ''::bytea) || ROW(col1, ..., NULL::bytea, ..., coln)::text::bytea, 'sha256')
* 2. The rebuilt hashes match what VerifyAuditChains expects (validates as intact).
* 3. --dry-run does not modify hashes.
* 4. Unknown partition names are rejected.
*
* Note: we use direct SQL verification (mirroring VerifyAuditChains logic)
* rather than calling audit:verify-chains, because the full command checks ALL
* partitions and a pre-existing mismatch in any other partition would cause
* false failure. This keeps the test focused on our specific partition.
*/
/**
* Check chain integrity for a specific partition using the same SQL as VerifyAuditChains.
* Returns the count of mismatched rows (0 = intact).
*/
function checkPartitionIntegrity(string $partition, string $partitionClause, string $rowExpr): int
{
$overClause = $partitionClause !== ''
? "({$partitionClause} ORDER BY id)"
: '(ORDER BY id)';
$sql = <<<SQL
WITH ordered AS (
SELECT
id,
log_hash AS stored_hash,
LAG(log_hash) OVER {$overClause} AS prev_hash
FROM {$partition}
)
SELECT count(*) AS cnt
FROM ordered o
WHERE o.stored_hash IS DISTINCT FROM
digest(
COALESCE(o.prev_hash, ''::bytea)
|| (SELECT {$rowExpr}::text::bytea FROM {$partition} t WHERE t.id = o.id),
'sha256'
)
SQL;
$result = DB::connection('pgsql_supplier')->selectOne($sql);
return (int) ($result?->cnt ?? 0);
}
// Column list for activity_log (must match VerifyAuditChains::TABLE_CONFIG).
const ACTIVITY_LOG_ROW_EXPR = 'ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)';
// Column list for balance_transactions (must match VerifyAuditChains::TABLE_CONFIG).
const BALANCE_TX_ROW_EXPR = 'ROW(t.id, t.tenant_id, t.type, t.amount_rub, t.amount_leads, t.balance_rub_after, t.balance_leads_after, t.description, t.related_type, t.related_id, t.user_id, t.admin_user_id, NULL::bytea, t.created_at)';
it('audit:rebuild-chain repairs broken hash chain from given id in activity_log', function (): void {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
// Insert 3 valid rows via normal flow (trigger writes correct hashes).
DB::table('activity_log')->insert([
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.created', 'context' => null, 'created_at' => now()],
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.updated', 'context' => null, 'created_at' => now()->addMicrosecond()],
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.closed', 'context' => null, 'created_at' => now()->addMicroseconds(2)],
]);
$rows = DB::table('activity_log')
->where('tenant_id', $tenant->id)
->orderBy('id')
->get(['id', 'log_hash', 'event']);
expect($rows)->toHaveCount(3);
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
// Verify initial state: chain is intact for our tenant's rows.
$initialMismatches = checkPartitionIntegrity(
$partition,
'PARTITION BY tenant_id',
ACTIVITY_LOG_ROW_EXPR,
);
expect($initialMismatches)->toBe(0, 'Initial chain should be intact');
// Manually corrupt row 2's log_hash (simulating race-condition branch).
DB::statement("SET session_replication_role = 'replica'");
DB::statement('UPDATE activity_log SET log_hash = \'\\xdeadbeef\'::bytea WHERE id = '.$rows[1]->id);
DB::statement("SET session_replication_role = 'origin'");
// Verify: now there's a mismatch (row 2 + row 3 that depends on row 2).
$mismatchesBefore = checkPartitionIntegrity(
$partition,
'PARTITION BY tenant_id',
ACTIVITY_LOG_ROW_EXPR,
);
expect($mismatchesBefore)->toBeGreaterThan(0, 'Chain should have mismatch after corruption');
// Rebuild from the corrupted row onwards.
$fromId = $rows[1]->id;
$exitRebuild = Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
'--from-id' => $fromId,
'--force' => true,
]);
expect($exitRebuild)->toBe(0);
// Verify: chain is now intact again.
$mismatchesAfter = checkPartitionIntegrity(
$partition,
'PARTITION BY tenant_id',
ACTIVITY_LOG_ROW_EXPR,
);
expect($mismatchesAfter)->toBe(0, 'Chain should be intact after rebuild');
// Verify the hashes actually changed (the corrupt value was replaced).
$rebuilt = DB::table('activity_log')
->where('tenant_id', $tenant->id)
->where('id', '>=', $fromId)
->orderBy('id')
->pluck('log_hash');
foreach ($rebuilt as $hash) {
// BYTEA columns returned as PHP stream resources via PDO pgsql driver.
$bin = is_resource($hash) ? stream_get_contents($hash) : (string) $hash;
expect(bin2hex($bin))->not->toBe('deadbeef')
->and(strlen($bin))->toBe(32); // sha256 = 32 bytes
}
});
it('audit:rebuild-chain works for balance_transactions partition', function (): void {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
DB::table('balance_transactions')->insert([
['tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => 100, 'amount_leads' => 0, 'created_at' => now()],
['tenant_id' => $tenant->id, 'type' => 'lead_charge', 'amount_rub' => -10, 'amount_leads' => 0, 'created_at' => now()->addMicrosecond()],
]);
$rows = DB::table('balance_transactions')
->where('tenant_id', $tenant->id)
->orderBy('id')
->get(['id', 'log_hash']);
expect($rows)->toHaveCount(2);
$partition = 'balance_transactions_y'.now()->format('Y').'_m'.now()->format('m');
// Corrupt second row.
DB::statement("SET session_replication_role = 'replica'");
DB::statement('UPDATE balance_transactions SET log_hash = \'\\xbaadf00d\'::bytea WHERE id = '.$rows[1]->id);
DB::statement("SET session_replication_role = 'origin'");
$mismatchesBefore = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', BALANCE_TX_ROW_EXPR);
expect($mismatchesBefore)->toBeGreaterThan(0);
$exit = Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
'--from-id' => $rows[1]->id,
'--force' => true,
]);
expect($exit)->toBe(0);
$mismatchesAfter = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', BALANCE_TX_ROW_EXPR);
expect($mismatchesAfter)->toBe(0, 'Balance transaction chain should be intact after rebuild');
});
it('audit:rebuild-chain --dry-run does not modify hashes', function (): void {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
DB::table('activity_log')->insert([
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'dry.run.test', 'context' => null, 'created_at' => now()],
]);
$row = DB::table('activity_log')
->where('tenant_id', $tenant->id)
->orderByDesc('id')
->first(['id', 'log_hash']);
// Corrupt the hash.
DB::statement("SET session_replication_role = 'replica'");
DB::statement('UPDATE activity_log SET log_hash = \'\\xcafebabe\'::bytea WHERE id = '.$row->id);
DB::statement("SET session_replication_role = 'origin'");
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
'--from-id' => $row->id,
'--dry-run' => true,
]);
// Hash must remain corrupted — dry-run made no changes.
// BYTEA columns are returned as PHP stream resources via PDO pgsql driver.
$afterRaw = DB::table('activity_log')->where('id', $row->id)->value('log_hash');
$afterBin = is_resource($afterRaw) ? stream_get_contents($afterRaw) : (string) $afterRaw;
expect(bin2hex($afterBin))->toBe('cafebabe');
});
it('audit:rebuild-chain rejects unknown partition names', function (): void {
Artisan::call('audit:rebuild-chain', [
'--partition' => 'deals_y2026_m05', // not an audit table
'--from-id' => 1,
'--force' => true,
]);
expect(Artisan::output())->toContain('поддерживаемым аудит-таблицам');
});
// ──────────────────────────────────────────────────────────────────────────────
// ADR-018 Task 3: failing tests для per-tenant rebuild (RED phase).
// После Task 4 (per-tenant LAG OVER) — должны стать PASS.
// ──────────────────────────────────────────────────────────────────────────────
// Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']).
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';
it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
// Tenant A — 2 rows.
DB::statement('SET app.current_tenant_id = '.$tenantA->id);
DB::table('activity_log')->insert([
['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a1', 'context' => null, 'created_at' => now()],
['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a2', 'context' => null, 'created_at' => now()->addMicrosecond()],
]);
// Tenant B — 2 rows (interleaved IDs with tenant A, но цепочка независимая per-tenant).
DB::statement('SET app.current_tenant_id = '.$tenantB->id);
DB::table('activity_log')->insert([
['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b1', 'context' => null, 'created_at' => now()->addMicroseconds(2)],
['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b2', 'context' => null, 'created_at' => now()->addMicroseconds(3)],
]);
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
// NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env
// `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет
// global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant
// (валидация — live `audit:verify-chains` на проде, не в этом тесте).
//
// Что тестируется здесь: AFTER rebuild чейн должен match семантике своего
// partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG →
// verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4
// rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN.
$exit = Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
'--from-id' => $firstId,
'--force' => true,
]);
expect($exit)->toBe(0);
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)');
});
it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
// auth_log пишется под BYPASSRLS pre-auth role. INSERT direct через pgsql_supplier.
DB::connection('pgsql_supplier')->table('auth_log')->insert([
['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'a@x.com', 'created_at' => now()],
['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'b@x.com', 'created_at' => now()->addMicrosecond()],
]);
$partition = 'auth_log_y'.now()->format('Y').'_m'.now()->format('m');
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
$preMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
expect($preMismatches)->toBe(0, 'Trigger writes global chain correctly for auth_log');
$exit = Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
'--from-id' => $firstId,
'--force' => true,
]);
expect($exit)->toBe(0);
$postMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Rebuild должен сохранить global chain для BYPASSRLS-таблицы');
});
it('audit:rebuild-chain handles single-row partition (first row of tenant) корректно', function (): void {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
DB::table('activity_log')->insert([
'tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1,
'event' => 'deal.solo', 'context' => null, 'created_at' => now(),
]);
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)
->where('tenant_id', $tenant->id)
->min('id');
$exit = Artisan::call('audit:rebuild-chain', [
'--partition' => $partition,
'--from-id' => $firstId,
'--force' => true,
]);
expect($exit)->toBe(0);
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
expect($postMismatches)->toBe(0, 'Single-row per-tenant partition должен остаться intact');
});