307a65e786
Test env (`SharesSupplierPdo` trait + postgres superuser) обходит RLS, поэтому trigger `audit_chain_hash()` в тестах пишет global chain, не per-tenant. Это расхождение с prod (где RLS активен и trigger пишет per-tenant) валидно — но делает pre-rebuild sanity-check невыполнимым assumption'ом. Multi-tenant test теперь проверяет только self-consistency post-rebuild: rebuild должен produce chain matching своему partition_clause. Pre-Task-4 (global LAG): post-rebuild verify с PARTITION BY tenant_id → mismatch → RED (текущее состояние). Post-Task-4 (per-tenant LAG): post-rebuild verify с PARTITION BY tenant_id → match → GREEN. Prod RLS-aware trigger semantics валидируется live `audit:verify-chains`, не в этом тесте. Ref: docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
325 lines
15 KiB
PHP
325 lines
15 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('поддерживаемым аудит-таблицам');
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
// 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');
|
||
});
|