116 lines
4.4 KiB
PHP
116 lines
4.4 KiB
PHP
|
|
<?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)');
|
|||
|
|
});
|