Files
portal/app/tests/Feature/RlsSmokeTest.php
T
Дмитрий 6e1f5355b8 refactor(webhook): Phase 4 — DROP migration + schema v8.35 + test/factory cleanup
Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.

Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).

Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)

Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token

Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35

Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54

Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:51:17 +03:00

116 lines
4.9 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',
'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',
'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);
});