b502db8fdc
Replays sha256 chain in given audit partition from given id: 1. Uses pgsql_supplier (BYPASSRLS) to see all rows regardless of RLS scope. 2. Bypasses audit_block_mutation trigger via session_replication_role=replica (session-local SET, does not affect other connections). 3. Recomputes hash per row using the same formula as audit_chain_hash(): digest(COALESCE(prev_hash,''::bytea) || ROW(col1,...,NULL::bytea,...,coln)::text::bytea, 'sha256') Column order from COLUMN_CONFIG matches TABLE_CONFIG in VerifyAuditChains. 4. Supports --dry-run (count without UPDATE) and --force (skip confirmation). Validated against breaking partitions: --partition=activity_log_y2026_m05 --from-id=599 --partition=balance_transactions_y2026_m05 --from-id=462 Tests: 4 tests — activity_log rebuild, balance_transactions rebuild, dry-run no-op, unknown partition rejection. All pass (4/4 GREEN). Refs: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
8.9 KiB
PHP
226 lines
8.9 KiB
PHP
<?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('поддерживаемым аудит-таблицам');
|
|
});
|