Files
portal/app/tests/Feature/Integration/LeadChargesRlsTest.php
T
Дмитрий 7f694f78e0 feat(db): create lead_charges ledger (tenant-scoped RLS, FK to partitioned deals)
Append-only ledger списаний за каждый доставленный лид. Tenant-scoped
с RLS tenant_isolation (ENABLE + FORCE + USING/WITH CHECK).

- 8 columns: id, tenant_id, deal_id, deal_received_at, tier_no,
  price_per_lead_kopecks, charged_at, created_at
- Composite FK lead_charges_deals_fk(deal_id, deal_received_at) →
  deals(id, received_at) DEFERRABLE INITIALLY DEFERRED
  (deals партиционирована — DEFERRABLE для атомарного deal+charge)
- 2 индекса: (tenant_id, charged_at), (deal_id, deal_received_at)
- RLS на (tenant_id = current_setting('app.current_tenant_id')::bigint)
- GRANT SELECT, INSERT для crm_app_user (без UPDATE/DELETE — append-only)

Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4
Plan: docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md Task 4

Test: 3/3 passed (table exists, composite FK to deals, RLS enforces
tenant isolation via testing_rls_user role).

Schema v8.14 → v8.15. Метрики: 59 таблиц (+1) / 105 индексов (+2) /
39 RLS (+1) / функции/триггеры без изменений.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:38:17 +03:00

116 lines
4.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Plan 1/5 Task 4: lead_charges ledger — tenant-scoped RLS, FK to partitioned deals.
*
* Покрывает:
* - Структура таблицы (8 колонок).
* - Composite FK на (deals.id, deals.received_at) DEFERRABLE INITIALLY DEFERRED
* (deals партиционирована — обычный FK не работает; deferred нужен для
* atomic INSERT deal+charge в одной транзакции).
* - RLS-изоляция: charge tenant'а A не виден из tenant'а B.
*
* RLS-проверка использует testing_rls_user (NOLOGIN, NO BYPASSRLS) — postgres
* superuser обходит RLS даже при FORCE. Паттерн заимствован из RlsSmokeTest.
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
// Идемпотентное создание testing-роли (роль — глобальный объект, переживает ROLLBACK).
DB::unprepared(<<<'SQL'
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'testing_rls_user') THEN
CREATE ROLE testing_rls_user NOLOGIN;
GRANT USAGE ON SCHEMA public TO testing_rls_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO testing_rls_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO testing_rls_user;
END IF;
END
$$;
SQL);
// Идемпотентный grant на новые таблицы/последовательности (lead_charges создан
// после возможного предшествующего создания роли — ALL TABLES в beforeEach
// RlsSmokeTest применяется только при первом CREATE ROLE).
DB::statement('GRANT SELECT, INSERT ON TABLE lead_charges TO testing_rls_user');
DB::statement('GRANT USAGE, SELECT ON SEQUENCE lead_charges_id_seq TO testing_rls_user');
});
test('lead_charges table exists with required columns', function () {
expect(Schema::hasTable('lead_charges'))->toBeTrue();
foreach ([
'id',
'tenant_id',
'deal_id',
'deal_received_at',
'tier_no',
'price_per_lead_kopecks',
'charged_at',
'created_at',
] as $col) {
expect(Schema::hasColumn('lead_charges', $col))->toBeTrue("column {$col} missing");
}
});
test('lead_charges has FK to deals (composite id, received_at)', function () {
$fk = DB::selectOne(
"SELECT pg_get_constraintdef(c.oid) AS def
FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'lead_charges' AND c.contype = 'f'
AND pg_get_constraintdef(c.oid) LIKE '%deals%'
LIMIT 1"
);
expect($fk)->not->toBeNull();
expect($fk->def)
->toContain('deals')
->toContain('received_at')
->toContain('id');
});
test('lead_charges enforces RLS on tenant_id', function () {
// Готовим tenant + project + deal под superuser (BYPASSRLS), чтобы FK прошёл.
$a = Tenant::factory()->create();
$b = Tenant::factory()->create();
$projectA = Project::factory()->for($a)->create();
$dealA = Deal::factory()->for($a)->for($projectA)->create();
// Переключаемся на NO-BYPASSRLS роль для проверки изоляции.
DB::statement('SET LOCAL ROLE testing_rls_user');
DB::statement("SET LOCAL app.current_tenant_id = '{$a->id}'");
DB::table('lead_charges')->insert([
'tenant_id' => $a->id,
'deal_id' => $dealA->id,
'deal_received_at' => $dealA->received_at,
'tier_no' => 1,
'price_per_lead_kopecks' => 6000,
'charged_at' => now(),
'created_at' => now(),
]);
// Под tenant A — видно
$countA = DB::table('lead_charges')->count();
// Переключаемся на tenant B — RLS должен скрыть
DB::statement("SET LOCAL app.current_tenant_id = '{$b->id}'");
$countB = DB::table('lead_charges')->count();
expect($countA)->toBe(1);
expect($countB)->toBe(0, 'tenant B must not see tenant A charges (RLS leak)');
});