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