Files
portal/app/tests/Feature/RlsSmokeTest.php
T
Дмитрий c6f9c62da0 phase1(rls-smoke): CTO-13 — Pest 4 RLS smoke-test (4/4 passed, 662 ms)
Реализованы кейсы 1 + 4 из Прил. И Г.1 «CTO-13: RLS smoke-test через
PgBouncer» как первая проверка RLS-фундамента schema v8.6 ДО первого
PR с tenant-моделью.

app/tests/Feature/RlsSmokeTest.php (NEW):

- кейс 1 (× 2 теста): SET LOCAL app.current_tenant_id изолирует SELECT
  в deals — оба тенанта видят только свои 2 deals из 4 общих.
- кейс 1 расширенный: RLS работает на projects (не только deals) —
  тот же tenant-контекст применяется ко всем 36 политикам.
- кейс 4: WITH CHECK блокирует INSERT в projects с чужим tenant_id —
  ожидается QueryException (RLS WITH CHECK violation).

Стек теста:

- testing-роль `testing_rls_user` NOLOGIN (создаётся идемпотентно через
  DO $$ ... IF NOT EXISTS $$). На dev superuser обходит RLS — поэтому
  через SET LOCAL ROLE переключаемся на NOLOGIN-роль без BYPASSRLS.
- DatabaseTransactions trait вместо RefreshDatabase — каждый тест в
  транзакции, ROLLBACK сбрасывает SET LOCAL ROLE и тестовые данные.
- Отдельная БД liderra_testing (создана `CREATE DATABASE` через psql,
  мигрирована `DB_DATABASE=liderra_testing artisan migrate:fresh` 743 ms).
- phpunit.xml: DB_CONNECTION sqlite → pgsql, DB_DATABASE liderra_testing.

Pest 6/6 passed (RlsSmokeTest 4/4 + ExampleTest 2/2) за 723 ms total.

Кейсы НЕ покрытые (отложены):

- Кейс 2-3 (PgBouncer transaction-pooling reuse, job retry): production-
  только, на native Windows-стеке нет PgBouncer
- Кейс 5 (REVOKE на 6 saas-таблицах для crm_app_user): требует ролей
  из db/02_grants.sql, на dev не созданы (только postgres-superuser)

Сопутствующие правки:

- .gitleaks.toml: + allowlist path для app/tests/*.php (фиктивные
  телефоны вида +79000010001 в фикстурах — не реальные ПДн)
- app/phpstan-baseline.neon: regenerated — Pest dynamic $this properties
  ($this->tenant1Id и т.п.) не парсятся PHPStan без pest-extension,
  занесены в baseline (12 entries) до миграции на typed-properties
- CLAUDE.md §6: Pest 2/2 → 6/6, добавлено упоминание CTO-13 smoke-test
- memory project_state.md, MEMORY.md: smoke-test реализован

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:00:43 +03:00

118 lines
5.1 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 Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* CTO-13: RLS smoke-test.
*
* Проверяет ключевые сценарии Row Level Security из Прил. И «Часть Г.1»:
* - Кейс 1: SET LOCAL app.current_tenant_id изолирует SELECT по tenant_id.
* - Кейс 4: WITH CHECK блокирует INSERT в чужой тенант.
*
* Кейсы 2-3 (PgBouncer pooling, job retry) — отдельно в production-окружении
* через Прил. И Г.1 (PgBouncer на native Windows-стеке нет).
*
* Кейс 5 (REVOKE на 6 saas-таблицах) — требует роли `crm_app_user` из
* db/00_create_roles.sql + db/02_grants.sql, на dev superuser обходит REVOKE.
*
* Стратегия: для эмуляции crm_app_user на dev-машине создаётся NOLOGIN-роль
* `testing_rls_user` без BYPASSRLS. SET LOCAL ROLE внутри транзакции —
* сбрасывается ROLLBACK'ом DatabaseTransactions.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
// Идемпотентное создание testing-роли (роль = глобальный объект, не Rolled Back).
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);
// 2 тенанта для проверки изоляции
$this->tenant1Id = DB::table('tenants')->insertGetId([
'subdomain' => 'rls-tenant-a-'.uniqid(),
'organization_name' => 'RLS Tenant A',
'contact_email' => 'a@rls-test.local',
'webhook_token' => 'whtA'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
'api_key_limit' => 5,
]);
$this->tenant2Id = DB::table('tenants')->insertGetId([
'subdomain' => 'rls-tenant-b-'.uniqid(),
'organization_name' => 'RLS Tenant B',
'contact_email' => 'b@rls-test.local',
'webhook_token' => 'whtB'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
'api_key_limit' => 5,
]);
$this->project1Id = DB::table('projects')->insertGetId([
'tenant_id' => $this->tenant1Id,
'name' => 'Project A1',
]);
$this->project2Id = DB::table('projects')->insertGetId([
'tenant_id' => $this->tenant2Id,
'name' => 'Project B1',
]);
DB::table('deals')->insert([
['tenant_id' => $this->tenant1Id, 'project_id' => $this->project1Id, 'phone' => '+79000010001', 'received_at' => now()],
['tenant_id' => $this->tenant1Id, 'project_id' => $this->project1Id, 'phone' => '+79000010002', 'received_at' => now()],
['tenant_id' => $this->tenant2Id, 'project_id' => $this->project2Id, 'phone' => '+79000020001', 'received_at' => now()],
['tenant_id' => $this->tenant2Id, 'project_id' => $this->project2Id, 'phone' => '+79000020002', 'received_at' => now()],
]);
});
test('кейс 1: SET LOCAL app.current_tenant_id изолирует SELECT в deals', function () {
DB::statement('SET LOCAL ROLE testing_rls_user');
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant1Id}'");
$allDealsVisible = DB::table('deals')->count();
$foreignDealsVisible = DB::table('deals')->where('tenant_id', $this->tenant2Id)->count();
expect($allDealsVisible)->toBe(2);
expect($foreignDealsVisible)->toBe(0);
});
test('кейс 1: переключение tenant_id показывает только свои deals', function () {
DB::statement('SET LOCAL ROLE testing_rls_user');
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant2Id}'");
$allDealsVisible = DB::table('deals')->count();
$foreignDealsVisible = DB::table('deals')->where('tenant_id', $this->tenant1Id)->count();
expect($allDealsVisible)->toBe(2);
expect($foreignDealsVisible)->toBe(0);
});
test('кейс 1: RLS работает на projects и users (не только deals)', function () {
DB::statement('SET LOCAL ROLE testing_rls_user');
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant1Id}'");
$projectsVisible = DB::table('projects')->count();
$foreignProjectsVisible = DB::table('projects')->where('tenant_id', $this->tenant2Id)->count();
expect($projectsVisible)->toBe(1);
expect($foreignProjectsVisible)->toBe(0);
});
test('кейс 4: WITH CHECK блокирует INSERT projects с чужим tenant_id', function () {
DB::statement('SET LOCAL ROLE testing_rls_user');
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant1Id}'");
expect(fn () => DB::table('projects')->insert([
'tenant_id' => $this->tenant2Id,
'name' => 'Hijack attempt',
]))->toThrow(QueryException::class);
});