c6f9c62da0
Реализованы кейсы 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>
118 lines
5.1 KiB
PHP
118 lines
5.1 KiB
PHP
<?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);
|
||
});
|