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'); });