60ab5be3eb
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) — отдельным шагом с явным approve. Решения заказчика: * Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log, webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log) * FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log * Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м, balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК * Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже работает per-partition; совместимо с hole #1 per-RLS-scope fix) Phase A: * db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE, PK→(id, partition_key), +7 retention seeds в system_settings, FK от failed_webhook_jobs/rejected_deals_log удалены * MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array (name => partition_key), 2 → 9 таблиц * PartitionsCreateMonths: автоматически покрывает все 9 * load_initial_schema: после schema.sql вызывает Artisan partitions:create-months --ahead=2 (без этого первый INSERT падает) * 2026_05_22_000001_tenant_operations_log: idempotency guard * VerifyAuditChains: per-partition scan через pg_inherits; fallback на single-scope для не-партиционированной таблицы; per-RLS-scope partition_clause сохранён внутри каждой партиции * AuditChainBreachMail: +partitionName param (NULL=fallback на tableName) * PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из system_settings, dry-run mode, safety guard retention=0 * SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин) Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh. Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup). Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail — pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_* (FK actor_admin_user_id после partitioning pd_processing_log, отдельная задача для hole #4 follow-up, не блокирует). cspell +2 слова (партиционировать, дёшева). Pint --fix чистый. Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
186 lines
7.7 KiB
PHP
186 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Services\MonthlyPartitionManager;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Guard: check whether auth_log is partitioned. Tests in this file require
|
|
// the partition_audit_tables migration to have run (hole #2, 23.05.2026).
|
|
// If it hasn't been applied yet, every CREATE TABLE ... PARTITION OF call will
|
|
// fail with "relation is not partitioned". We detect this once and skip.
|
|
// ---------------------------------------------------------------------------
|
|
function authLogIsPartitioned(): bool
|
|
{
|
|
return DB::selectOne(
|
|
"SELECT 1 AS ok
|
|
FROM pg_class c
|
|
JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
|
|
WHERE c.relname = 'auth_log'",
|
|
) !== null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: set or remove partition retention in system_settings.
|
|
// ---------------------------------------------------------------------------
|
|
function setRetention(string $table, ?int $months): void
|
|
{
|
|
$key = "partition_retention_months_{$table}";
|
|
|
|
if ($months === null) {
|
|
DB::table('system_settings')->where('key', $key)->delete();
|
|
|
|
return;
|
|
}
|
|
|
|
DB::table('system_settings')->upsert(
|
|
[
|
|
'key' => $key,
|
|
'value' => (string) $months,
|
|
'type' => 'int',
|
|
'description' => "Test retention for {$table}",
|
|
'updated_at' => now(),
|
|
],
|
|
['key'],
|
|
['value', 'updated_at'],
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: create a test partition for a given table and month.
|
|
// Returns partition name (e.g. auth_log_y2026_m02).
|
|
// ---------------------------------------------------------------------------
|
|
function createTestPartition(string $table, Carbon $monthStart): string
|
|
{
|
|
/** @var MonthlyPartitionManager $mgr */
|
|
$mgr = app(MonthlyPartitionManager::class);
|
|
$mgr->ensureMonth($table, $monthStart);
|
|
|
|
return $mgr->partitionName($table, $monthStart);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: check whether a partition physically exists in pg_class.
|
|
// Named with "Drop" prefix to avoid collision with MonthlyPartitionManagerTest.
|
|
// ---------------------------------------------------------------------------
|
|
function dropExpiredPartitionExists(string $partitionName): bool
|
|
{
|
|
return DB::selectOne(
|
|
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
|
[$partitionName],
|
|
) !== null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared teardown: reset Carbon fake time after each test.
|
|
// ---------------------------------------------------------------------------
|
|
afterEach(function () {
|
|
Carbon::setTestNow(null);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Tests
|
|
// ===========================================================================
|
|
|
|
test('dry-run: partition is not dropped', function () {
|
|
Carbon::setTestNow('2026-05-15');
|
|
|
|
$oldMonth = Carbon::create(2026, 2, 1)->startOfMonth(); // 3 months ago
|
|
$partition = createTestPartition('auth_log', $oldMonth);
|
|
setRetention('auth_log', 1); // cutoff = 2026-04, so 2026-02 would normally drop
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue('Partition must exist before command');
|
|
|
|
$this->artisan('partitions:drop-expired --dry-run')->assertSuccessful();
|
|
|
|
// Dry-run never physically drops
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue('Dry-run must NOT drop the partition');
|
|
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
|
|
|
test('drops partition older than retention boundary', function () {
|
|
Carbon::setTestNow('2026-05-15');
|
|
|
|
// retention=2: cutoff = 2026-03; 2026-02 is strictly older → drop
|
|
$oldMonth = Carbon::create(2026, 2, 1)->startOfMonth();
|
|
$partition = createTestPartition('auth_log', $oldMonth);
|
|
setRetention('auth_log', 2);
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
|
|
|
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeFalse('Partition beyond retention must be dropped');
|
|
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
|
|
|
test('does not drop partition at the retention boundary (inclusive keep)', function () {
|
|
Carbon::setTestNow('2026-05-15');
|
|
|
|
// retention=3: cutoff = 2026-02; 2026-02 is NOT strictly less than cutoff → keep
|
|
$boundaryMonth = Carbon::create(2026, 2, 1)->startOfMonth();
|
|
$partition = createTestPartition('auth_log', $boundaryMonth);
|
|
setRetention('auth_log', 3);
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
|
|
|
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue('Partition at boundary must NOT be dropped');
|
|
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
|
|
|
test('skips table when retention is not configured', function () {
|
|
Carbon::setTestNow('2026-05-15');
|
|
|
|
setRetention('auth_log', null); // remove any retention setting
|
|
|
|
$oldMonth = Carbon::create(2025, 1, 1)->startOfMonth();
|
|
$partition = createTestPartition('auth_log', $oldMonth);
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
|
|
|
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue('No retention config → nothing dropped');
|
|
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
|
|
|
test('skips table when retention value is 0 (safety guard)', function () {
|
|
Carbon::setTestNow('2026-05-15');
|
|
|
|
$oldMonth = Carbon::create(2024, 1, 1)->startOfMonth();
|
|
$partition = createTestPartition('auth_log', $oldMonth);
|
|
setRetention('auth_log', 0);
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
|
|
|
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
|
|
|
expect(dropExpiredPartitionExists($partition))->toBeTrue('retention=0 must be blocked — nothing dropped');
|
|
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
|
|
|
test('keeps recent partitions, drops only expired ones', function () {
|
|
Carbon::setTestNow('2026-05-15');
|
|
|
|
// retention=2 → cutoff = 2026-03
|
|
// keep: 2026-05 (current), 2026-04 (1mo ago), 2026-03 (boundary)
|
|
// drop: 2026-02 (3mo ago), 2026-01 (4mo ago)
|
|
setRetention('auth_log', 2);
|
|
|
|
$keep1 = createTestPartition('auth_log', Carbon::create(2026, 5, 1));
|
|
$keep2 = createTestPartition('auth_log', Carbon::create(2026, 4, 1));
|
|
$keep3 = createTestPartition('auth_log', Carbon::create(2026, 3, 1));
|
|
$drop1 = createTestPartition('auth_log', Carbon::create(2026, 2, 1));
|
|
$drop2 = createTestPartition('auth_log', Carbon::create(2026, 1, 1));
|
|
|
|
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
|
|
|
expect(dropExpiredPartitionExists($keep1))->toBeTrue('2026-05 must be kept (current)');
|
|
expect(dropExpiredPartitionExists($keep2))->toBeTrue('2026-04 must be kept (within retention)');
|
|
expect(dropExpiredPartitionExists($keep3))->toBeTrue('2026-03 must be kept (at boundary)');
|
|
expect(dropExpiredPartitionExists($drop1))->toBeFalse('2026-02 must be dropped');
|
|
expect(dropExpiredPartitionExists($drop2))->toBeFalse('2026-01 must be dropped');
|
|
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|