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('поддерживаемым аудит-таблицам'); });