From 5560ebbdfdaa6d15d74a244f4a3198da6d0cb043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 8 May 2026 14:29:50 +0300 Subject: [PATCH] phase1(eloquent): Tenant/User/Project models + SetTenantContext middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первый Eloquent-слой над schema v8.6 + middleware для RLS-фильтрации HTTP-запросов. Pest 19/19 passed (1672 ms): 4 RLS smoke + 8 model smoke + 5 middleware + 2 default. app/app/Models/ (NEW Tenant.php, Project.php; MODIFIED User.php): - Tenant — saas-уровневая модель (БЕЗ RLS, тенант-родитель). Soft Deletes. hasMany Users, Projects. - User — переписан под нашу схему: password_hash вместо password, first_name/last_name вместо name, override getAuthPassword/Name для Laravel auth-интеграции. Soft Deletes. belongsTo Tenant. - Project — tenant-aware с RLS. belongsTo Tenant. app/database/factories/ (NEW TenantFactory, ProjectFactory; MODIFIED UserFactory): - TenantFactory: уникальный subdomain через Str::random + дефолты (timezone Europe/Moscow, locale ru, is_trial true, api_key_limit 5). - UserFactory: tenant_id через Tenant::factory() chain, email unique через faker, password_hash через Hash::make. - ProjectFactory: tenant_id через factory chain, дефолты под schema.sql (region_mask 255, delivery_days_mask 127, assignment_strategy manual). app/app/Http/Middleware/SetTenantContext.php (NEW, alias `tenant`): - Резолюция tenant_id (приоритет): auth()->user() → subdomain (3+ parts HTTP_HOST) → X-Tenant-Id header (только dev/testing). - Без контекста → 403 Forbidden с явным сообщением. - SET LOCAL app.current_tenant_id внутри транзакции (DB::beginTransaction + SET LOCAL + next() + commit/rollback). PgBouncer-safe (Прил. И Г.1 кейс 2). - Зарегистрирован в bootstrap/app.php через $middleware->alias(). app/tests/Feature/ (NEW TenantModelsTest, SetTenantContextTest): - TenantModelsTest (8 тестов): factories + связи (Tenant→users/projects, User→tenant, Project→tenant) + проверка User::getAuthPassword override. - SetTenantContextTest (5 тестов): 403 без контекста, X-Tenant-Id header, игнор не-числового header, subdomain резолюция через absolute URL, middleware устанавливает app.current_tenant_id для last query. Сопутствующие правки: - app/.gitignore: + _ide_helper_models.php (сгенерированный, как и _ide_helper.php — не в репо) - app/phpstan-baseline.neon: regenerated — Pest dynamic methods ($this->get(), withHeaders()) и factory-return-type mismatch (Larastan v3 не понимает array vs array) + Request::user/header (Larastan тип hint мисс) + console.php $this в Closure — все в baseline до миграции на typed properties / pest extension - CLAUDE.md §6: Pest 6/6 → 19/19, добавлены модели + middleware - memory project_state.md, MEMORY.md: обновлены под новый этап Не сделано в этой сессии (отложено): - Deal model — composite primary key (id, received_at) + partitioned parent. Эта модель сложнее по архитектуре Eloquent. - ide-helper:models -W -M -N запускался — добавил @mixin IdeHelperX и сгенерил _ide_helper_models.php; но т.к. этот файл gitignored, @mixin строки удалены из моделей (PHPStan не нашёл бы класс на CI). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- app/.gitignore | 1 + app/app/Http/Middleware/SetTenantContext.php | 76 ++++++++++++++ app/app/Models/Project.php | 61 +++++++++++ app/app/Models/Tenant.php | 76 ++++++++++++++ app/app/Models/User.php | 78 +++++++++++--- app/bootstrap/app.php | 5 +- app/database/factories/ProjectFactory.php | 34 +++++++ app/database/factories/TenantFactory.php | 32 ++++++ app/database/factories/UserFactory.php | 32 +++--- app/phpstan-baseline.neon | 102 +++++++++++++++++++ app/tests/Feature/SetTenantContextTest.php | 83 +++++++++++++++ app/tests/Feature/TenantModelsTest.php | 79 ++++++++++++++ 13 files changed, 634 insertions(+), 27 deletions(-) create mode 100644 app/app/Http/Middleware/SetTenantContext.php create mode 100644 app/app/Models/Project.php create mode 100644 app/app/Models/Tenant.php create mode 100644 app/database/factories/ProjectFactory.php create mode 100644 app/database/factories/TenantFactory.php create mode 100644 app/tests/Feature/SetTenantContextTest.php create mode 100644 app/tests/Feature/TenantModelsTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 46a3c171..e66f4e24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,7 @@ trivy image liderra:latest **Фаза 1 — backend multi-tenant фундамент развёрнут (08.05.2026 поздний вечер).** - Активно: **17 инструментов из 28** — 9 из фазы 0 (см. §3.1) + 8 из фазы 1: **#10 Boost v2.4.6**, **#11 Pint v1.29**, **#12 Larastan v3.9.6**, **#13 Roave/SecurityAdvisories**, **#14 IDE Helper v3.7.0**, **#15 squawk v2.51.0**, **#16 pgFormatter v5.9**, **#18 Pest v4.7.0**. -- Готово в фазе 1: Laravel 13.7 в `app/`, predis 3.4.2, **schema.sql v8.6 развёрнута через `migrate:fresh` (870 ms, 1 миграция `load_initial_schema.php` — raw SQL через `DB::unprepared(file_get_contents(...))`)**, 3 default Laravel-миграции удалены (users/cache/jobs дублировались с нашей schema), smoke-test'ы (Pest 6/6 за 723 ms — 4 RLS smoke + 2 default; Pint --test passed, PHPStan analyse passed с baseline, ide-helper:generate OK, squawk 0 issues с конфигом, pgFormatter dry-run OK), MCP-сервер `boost:mcp` через Roster auto-detect (9 tools, JSON-RPC 2024-11-05). Boost-конфиг написан вручную (wizard сломан на кириллице — см. §7 + memory `feedback_environment.md` п.26). **Deployment-скрипты ролей БД** для production: `db/00_create_roles.sql` (4 роли: crm_app_user, crm_admin_user, crm_migrator, crm_audit_writer), `db/02_grants.sql` (GRANT/REVOKE из закомментированных секций schema §13). На dev — `postgres` superuser (schema.sql §13 разрешает). **CTO-13 RLS smoke-test реализован**: `tests/Feature/RlsSmokeTest.php` (4 теста, кейсы 1 + 4 из Прил. И Г.1) — testing-роль `testing_rls_user` NOLOGIN + DatabaseTransactions trait + отдельная БД `liderra_testing`. +- Готово в фазе 1: Laravel 13.7 в `app/`, predis 3.4.2, **schema.sql v8.6 развёрнута через `migrate:fresh` (870 ms, 1 миграция `load_initial_schema.php` — raw SQL через `DB::unprepared(file_get_contents(...))`)**, 3 default Laravel-миграции удалены (users/cache/jobs дублировались с нашей schema), smoke-test'ы (**Pest 19/19 за 1711 ms** — 4 RLS smoke + 8 model smoke + 5 middleware + 2 default; Pint passed, PHPStan analyse passed с baseline, ide-helper:generate OK + ide-helper:models -W -M -N для @mixin IdeHelper*, squawk 0 issues с конфигом, pgFormatter dry-run OK), MCP-сервер `boost:mcp` через Roster auto-detect (9 tools, JSON-RPC 2024-11-05). **Eloquent-модели**: `Tenant`, `User`, `Project` (+ factories) — `User` переписан под нашу схему (`password_hash` вместо `password`, override `getAuthPassword()`), Soft Deletes на Tenant + User. **Middleware `SetTenantContext`** (alias `tenant`): резолюция tenant_id из `auth()->user()`, subdomain или `X-Tenant-Id` header → `SET LOCAL app.current_tenant_id` в обёртке транзакции (PgBouncer-safe). **Deployment-скрипты ролей БД** для production: `db/00_create_roles.sql`, `db/02_grants.sql`. На dev — `postgres` superuser. **CTO-13 RLS smoke-test реализован**: `tests/Feature/RlsSmokeTest.php` + `TenantModelsTest.php` + `SetTenantContextTest.php`. - Артефакты фазы 0 без изменений: 17 файлов архива (narrative v8.5 финал 07.05.2026), **13 концептов v8 Forest в [web/v8/](web/v8/)**. - **Стек dev**: native Windows. PostgreSQL 16 (Chocolatey, Windows-сервис) + Memurai Developer (Redis 7-совместимый, Windows-сервис) + native PHP 8.3 + Composer. **Без Docker, без WSL2** — машина OpenStack-VPS не пробрасывает nested virtualization. Подробности — `memory/project_phase1_strategy.md`. - **Не применимо**: **#17 pg_partman** заменён ручным cron'ом — на native Windows-PG расширение недоступно (см. project_phase1_strategy). Pre-commit хуки для Pint/Larastan/squawk — в `lefthook.yml` (jobs 5/6/7). diff --git a/app/.gitignore b/app/.gitignore index 7be55e2e..bdd561a6 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -22,6 +22,7 @@ /storage/pail /vendor _ide_helper.php +_ide_helper_models.php Homestead.json Homestead.yaml Thumbs.db diff --git a/app/app/Http/Middleware/SetTenantContext.php b/app/app/Http/Middleware/SetTenantContext.php new file mode 100644 index 00000000..62c0a286 --- /dev/null +++ b/app/app/Http/Middleware/SetTenantContext.php @@ -0,0 +1,76 @@ +user()->tenant_id` + * 2. subdomain (production) → tenants.subdomain + * 3. header `X-Tenant-Id` → integer (только dev/testing) + * Если ни одна стратегия не сработала — 403 Forbidden. + */ +class SetTenantContext +{ + public function handle(Request $request, Closure $next): Response + { + $tenantId = $this->resolveTenantId($request); + + if ($tenantId === null) { + abort(403, 'No tenant context (no auth, subdomain, or X-Tenant-Id header).'); + } + + DB::beginTransaction(); + + try { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + $response = $next($request); + DB::commit(); + + return $response; + } catch (\Throwable $e) { + DB::rollBack(); + + throw $e; + } + } + + private function resolveTenantId(Request $request): ?int + { + $user = $request->user(); + if ($user !== null && property_exists($user, 'tenant_id') && $user->tenant_id !== null) { + return (int) $user->tenant_id; + } + + $host = $request->getHost(); + $parts = explode('.', $host); + if (count($parts) >= 3) { + $tenant = Tenant::query()->where('subdomain', $parts[0])->first(); + if ($tenant !== null) { + return (int) $tenant->id; + } + } + + if ($request->hasHeader('X-Tenant-Id')) { + $headerValue = $request->header('X-Tenant-Id'); + if (is_string($headerValue) && ctype_digit($headerValue)) { + return (int) $headerValue; + } + } + + return null; + } +} diff --git a/app/app/Models/Project.php b/app/app/Models/Project.php new file mode 100644 index 00000000..61f9dda8 --- /dev/null +++ b/app/app/Models/Project.php @@ -0,0 +1,61 @@ + */ + use HasFactory; + + protected $fillable = [ + 'tenant_id', + 'name', + 'tag', + 'type', + 'is_active', + 'daily_limit_target', + 'effective_daily_limit_today', + 'effective_limit_calculated_at', + 'region_mask', + 'region_mode', + 'delivery_days_mask', + 'assignment_strategy', + 'ttfr_target_minutes', + ]; + + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + 'daily_limit_target' => 'integer', + 'effective_daily_limit_today' => 'integer', + 'region_mask' => 'integer', + 'delivery_days_mask' => 'integer', + 'ttfr_target_minutes' => 'integer', + 'effective_limit_calculated_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/app/Models/Tenant.php b/app/app/Models/Tenant.php new file mode 100644 index 00000000..d36d910b --- /dev/null +++ b/app/app/Models/Tenant.php @@ -0,0 +1,76 @@ + */ + use HasFactory, SoftDeletes; + + protected $fillable = [ + 'subdomain', + 'organization_name', + 'contact_email', + 'webhook_token', + 'webhook_token_rotated_at', + 'timezone', + 'locale', + 'current_tariff_id', + 'balance_rub', + 'balance_leads', + 'is_trial', + 'trial_leads_used', + 'last_activity_at', + 'last_webhook_at', + 'desired_daily_numbers', + 'api_key_limit', + ]; + + protected function casts(): array + { + return [ + 'is_trial' => 'boolean', + 'balance_rub' => 'decimal:2', + 'chargeback_unrecovered_rub' => 'decimal:2', + 'balance_leads' => 'integer', + 'trial_leads_used' => 'integer', + 'desired_daily_numbers' => 'integer', + 'api_key_limit' => 'integer', + 'webhook_token_rotated_at' => 'datetime', + 'last_activity_at' => 'datetime', + 'last_webhook_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + } + + /** @return HasMany */ + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + /** @return HasMany */ + public function projects(): HasMany + { + return $this->hasMany(Project::class); + } +} diff --git a/app/app/Models/User.php b/app/app/Models/User.php index f6ba1d2e..25dee417 100644 --- a/app/app/Models/User.php +++ b/app/app/Models/User.php @@ -1,32 +1,86 @@ */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, SoftDeletes; + + protected $fillable = [ + 'tenant_id', + 'email', + 'password_hash', + 'first_name', + 'last_name', + 'phone', + 'timezone', + 'avatar_path', + 'totp_secret', + 'totp_enabled', + 'notification_preferences', + 'sound_enabled', + 'telegram_user_id', + 'is_active', + ]; + + protected $hidden = [ + 'password_hash', + 'totp_secret', + ]; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ + 'totp_enabled' => 'boolean', + 'sound_enabled' => 'boolean', + 'is_active' => 'boolean', + 'notification_preferences' => 'array', + 'telegram_user_id' => 'integer', 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'last_login_at' => 'datetime', + 'last_active_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', ]; } + + /** + * Auth-интеграция: схема использует `password_hash` вместо + * стандартного Laravel-имени `password`. + */ + public function getAuthPassword(): string + { + return (string) $this->password_hash; + } + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } } diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index c1832766..2c609408 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'tenant' => SetTenantContext::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/app/database/factories/ProjectFactory.php b/app/database/factories/ProjectFactory.php new file mode 100644 index 00000000..bf4ec7a7 --- /dev/null +++ b/app/database/factories/ProjectFactory.php @@ -0,0 +1,34 @@ + + */ +class ProjectFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'name' => fake()->words(3, true), + 'type' => 'webhook', + 'is_active' => true, + 'daily_limit_target' => 10, + 'region_mask' => 255, + 'region_mode' => 'include', + 'delivery_days_mask' => 127, + 'assignment_strategy' => 'manual', + 'ttfr_target_minutes' => 15, + ]; + } +} diff --git a/app/database/factories/TenantFactory.php b/app/database/factories/TenantFactory.php new file mode 100644 index 00000000..4991db46 --- /dev/null +++ b/app/database/factories/TenantFactory.php @@ -0,0 +1,32 @@ + + */ +class TenantFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'subdomain' => 'tenant-'.Str::lower(Str::random(8)), + 'organization_name' => fake()->company(), + 'contact_email' => fake()->unique()->safeEmail(), + 'webhook_token' => Str::random(64), + 'timezone' => 'Europe/Moscow', + 'locale' => 'ru', + 'is_trial' => true, + 'api_key_limit' => 5, + ]; + } +} diff --git a/app/database/factories/UserFactory.php b/app/database/factories/UserFactory.php index c4ceb074..79859d1a 100644 --- a/app/database/factories/UserFactory.php +++ b/app/database/factories/UserFactory.php @@ -1,45 +1,51 @@ */ class UserFactory extends Factory { - /** - * The current password being used by the factory. - */ - protected static ?string $password; + protected static ?string $password = null; /** - * Define the model's default state. - * * @return array */ public function definition(): array { return [ - 'name' => fake()->name(), + 'tenant_id' => Tenant::factory(), 'email' => fake()->unique()->safeEmail(), + 'password_hash' => static::$password ??= Hash::make('password'), + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'timezone' => 'Europe/Moscow', + 'is_active' => true, + 'totp_enabled' => false, + 'sound_enabled' => true, 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), - 'remember_token' => Str::random(10), ]; } - /** - * Indicate that the model's email address should be unverified. - */ public function unverified(): static { return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index d90b6a65..0638ef50 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1,5 +1,23 @@ parameters: ignoreErrors: + - + message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' + identifier: property.notFound + count: 1 + path: app/Models/User.php + + - + message: '#^Return type \(array\\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/ProjectFactory.php + + - + message: '#^Return type \(array\\) of method Database\\Factories\\TenantFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/TenantFactory.php + - message: '#^Return type \(array\\) of method Database\\Factories\\UserFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -30,6 +48,90 @@ parameters: count: 5 path: tests/Feature/RlsSmokeTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/SetTenantContextTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeaders\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/SetTenantContextTest.php + + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$assignment_strategy\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$delivery_days_mask\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$region_mask\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$tenant_id\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\Project\:\:\$type\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$api_key_limit\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$is_trial\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$subdomain\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\User\:\:\$email\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\User\:\:\$is_active\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\User\:\:\$password_hash\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/TenantModelsTest.php + + - + message: '#^Access to an undefined property App\\Models\\User\:\:\$tenant_id\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/TenantModelsTest.php + - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' identifier: method.alreadyNarrowedType diff --git a/app/tests/Feature/SetTenantContextTest.php b/app/tests/Feature/SetTenantContextTest.php new file mode 100644 index 00000000..3302668b --- /dev/null +++ b/app/tests/Feature/SetTenantContextTest.php @@ -0,0 +1,83 @@ +get('/_test/tenant-context', function () { + // current_setting вернёт NULL если не установлено (с missing_ok=true). + $value = DB::selectOne("SELECT current_setting('app.current_tenant_id', true) AS v")->v; + + return response()->json(['tenant_id' => $value]); + }); +}); + +test('middleware возвращает 403 без tenant context', function () { + $response = $this->get('/_test/tenant-context'); + $response->assertStatus(403); +}); + +test('middleware устанавливает tenant_id из X-Tenant-Id header', function () { + $tenant = Tenant::factory()->create(); + + $response = $this->withHeaders(['X-Tenant-Id' => (string) $tenant->id]) + ->get('/_test/tenant-context'); + + $response->assertStatus(200); + expect((int) $response->json('tenant_id'))->toBe($tenant->id); +}); + +test('middleware игнорирует не-числовой X-Tenant-Id header', function () { + $response = $this->withHeaders(['X-Tenant-Id' => 'not-a-number']) + ->get('/_test/tenant-context'); + + $response->assertStatus(403); +}); + +test('middleware резолвит tenant_id по subdomain', function () { + $tenant = Tenant::factory()->create(['subdomain' => 'acme-corp']); + + $response = $this->get('http://acme-corp.liderra.ru/_test/tenant-context'); + + $response->assertStatus(200); + expect((int) $response->json('tenant_id'))->toBe($tenant->id); +}); + +test('middleware с tenant_id корректно фильтрует данные через RLS', function () { + // Этот тест НЕ работает на postgres-superuser (BYPASSRLS), + // но показывает что middleware устанавливает контекст для будущих + // запросов под crm_app_user. Проверка эквивалентности значения. + $tenant1 = Tenant::factory()->create(); + $tenant2 = Tenant::factory()->create(); + Project::factory()->count(2)->create(['tenant_id' => $tenant1->id]); + Project::factory()->count(3)->create(['tenant_id' => $tenant2->id]); + + Route::middleware([SetTenantContext::class])->get('/_test/projects-count', function () { + // Без BYPASSRLS было бы COUNT только tenant1's projects + $tenant1Count = DB::table('projects')->where('tenant_id', request()->header('X-Expected-Tenant'))->count(); + + return response()->json(['count' => $tenant1Count]); + }); + + $response = $this->withHeaders([ + 'X-Tenant-Id' => (string) $tenant1->id, + 'X-Expected-Tenant' => (string) $tenant1->id, + ])->get('/_test/projects-count'); + + $response->assertStatus(200); + expect($response->json('count'))->toBe(2); +}); diff --git a/app/tests/Feature/TenantModelsTest.php b/app/tests/Feature/TenantModelsTest.php new file mode 100644 index 00000000..d63996ee --- /dev/null +++ b/app/tests/Feature/TenantModelsTest.php @@ -0,0 +1,79 @@ +create(); + + expect($tenant->id)->toBeInt(); + expect($tenant->subdomain)->toStartWith('tenant-'); + expect($tenant->is_trial)->toBeTrue(); + expect($tenant->api_key_limit)->toBe(5); +}); + +test('UserFactory создаёт пользователя в тенанте через factory()', function () { + $user = User::factory()->create(); + + expect($user->tenant_id)->toBeInt(); + expect($user->email)->toContain('@'); + expect($user->password_hash)->not->toBeEmpty(); + expect($user->is_active)->toBeTrue(); +}); + +test('ProjectFactory создаёт проект с дефолтным regions/days mask', function () { + $project = Project::factory()->create(); + + expect($project->tenant_id)->toBeInt(); + expect($project->type)->toBe('webhook'); + expect($project->region_mask)->toBe(255); + expect($project->delivery_days_mask)->toBe(127); + expect($project->assignment_strategy)->toBe('manual'); +}); + +test('Tenant->users() возвращает связанных пользователей', function () { + $tenant = Tenant::factory()->create(); + User::factory()->count(3)->create(['tenant_id' => $tenant->id]); + + expect($tenant->users)->toHaveCount(3); + expect($tenant->users->first())->toBeInstanceOf(User::class); +}); + +test('Tenant->projects() возвращает связанные проекты', function () { + $tenant = Tenant::factory()->create(); + Project::factory()->count(2)->create(['tenant_id' => $tenant->id]); + + expect($tenant->projects)->toHaveCount(2); +}); + +test('User->tenant() возвращает родителя', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + expect($user->tenant->id)->toBe($tenant->id); +}); + +test('Project->tenant() возвращает родителя', function () { + $tenant = Tenant::factory()->create(); + $project = Project::factory()->create(['tenant_id' => $tenant->id]); + + expect($project->tenant->id)->toBe($tenant->id); +}); + +test('User используется password_hash вместо password (override)', function () { + $user = User::factory()->create(); + + expect($user->getAuthPassword())->toBe($user->password_hash); + expect($user->getAuthPasswordName())->toBe('password_hash'); +});