Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md
|
||||
|
||||
Проект НЕ git: вместо коммитов — отчёты задач (task-N-report.md) и этот ledger.
|
||||
Ревью — по фактическим файлам дерева (diff-пакетов нет).
|
||||
|
||||
## Todos
|
||||
- [x] Task 1: Карта API (выполнена до плана — `docs/api/api-map.md`)
|
||||
- [x] Task 2: Персистентность — системный и tenant-контексты, миграции
|
||||
- Task 2: complete (review clean). Minor: (1) tenant-миграции легли в `Migrations/TenantDb/` (namespace `.TenantDb`) — нормализовать при желании через `--output-dir`; (2) Ruling 9 колонки PascalCase/`varchar(200)` вместо буквального SQL — согласовано с конвенцией кода; (3) `IX_users_Login` глобально-уникален — by design.
|
||||
- [x] Task 3: Модуль Tenants — домен и прикладные сервисы аутентификации
|
||||
- Task 3: complete (review clean; 25 PASS). Minor: (1) константы сессии/длины пароля приватны в AuthService — в Task 4 согласовать снаружи (IOptions) для cookie; (2) нормализация login lowercase — осознанно строже прототипа.
|
||||
- [x] Task 4: Эндпоинты auth, middleware сессии, DI, curl-приёмка
|
||||
- Task 4: complete (review clean; 25 PASS, curl 12/12). Minor: (1) `UnsafeRelaxedJsonEscaping` — ок для dev; (2) «30 дней» дублируется (AuthService vs Cookies) — унифицировать в Task 5; (3) Cookies продублирована в appsettings.json — осознанно. Временные заглушки StartupSeed через DealDbContext + PendingTenantProvisioner помечены «удалить в Task 5».
|
||||
- Task 4: complete (review clean; build 0/0, тесты 25 PASS, curl-приёмка :5080 — два прогона).
|
||||
Minor: (1) временная DI-заглушка PendingTenantProvisioner до Task 5 (on-build валидация TenantService);
|
||||
(2) seed создаёт пользователя через DealDbContext — в IAuthStore нет CreateUser; (3) Cookies-секция
|
||||
добавлена и в базовый appsettings.json (иначе Days=0 вне Development). Отчёт: task-4-report.md.
|
||||
- [x] Task 5: Провижининг схем тенантов и bootstrap при старте
|
||||
- Task 5: complete (review clean; build 0/0, тесты 25 PASS, приёмка :5080 — два старта).
|
||||
Minor: (1) psql-колонки PascalCase (EF default) — буквальные lowercase-запросы из задания падают, см. отчёт;
|
||||
(2) запуск apphost Deal.Api.exe напрямую вместо `dotnet run` (детерминированная остановка);
|
||||
(3) «30» — единый источник AuthService.SessionLifetimeDays; Cookies:Days убран из appsettings.
|
||||
Отчёт: task-5-report.md.
|
||||
- [x] Task 6: Финал этапа
|
||||
- Task 6: complete (review clean; техдок §13/§11/§4 обновлены). Отчёт: task-6-report.md.
|
||||
- **Этап 1 завершён**: финальное whole-scope ревью ✅ (build 0/0, 25 PASS, psql-схемы, live-curl auth 1:1, техдок фактичен). Миноры в этап 2: (1) ConnectionStrings:DealPostgres только в Development — вне dev нужен env; (2) tenant-миграции в `Migrations/TenantDb/`; (3) IX_users_Login глобально-уникален; (4) имя куки `deal_session` — при подключении реального фронта сверить.
|
||||
|
||||
## Pre-flight scan
|
||||
|
||||
| Пара | Производит / потребляет | Результат |
|
||||
|---|---|---|
|
||||
| T2 → T3 | T2 создаёт EF-сущности users/sessions/tenants/settings; T3-реализации (AuthStore) их читают | Чисто (реализации в Infrastructure видят Entities) |
|
||||
| T3 → T4 | T4-эндпоинты зовут AuthService модуля | Чисто |
|
||||
| T4 → T5 | T5 bootstrap создаёт дефолтного пользователя, которого логинит T4-приёмка | T5 идёт после T4; в T4 для curl-приёмки нужен seed admin — см. Ruling 8, внесён в T5. **Конфликт**: curl-приёмка T4 требует пользователя, которого создаёт T5. Резолв: T4 делает минимальный inline-seed (users) через тот же код bootstrap-хелпера, T5 формализует провижининг схем. |
|
||||
| T2 → T5 | T5 применяет tenant-миграции из T2 | Чисто |
|
||||
| T2 → T2 | пересоздание dev-БД (Ruling 4) | Dev-данных нет — безопасно |
|
||||
| T3 | модуль не ссылается на Infrastructure | Проверить в ревью (циклов быть не должно) |
|
||||
| T5 | `MigrationsHistoryTable("__TenantMigrationsHistory", schema)` | Подтвердить в ревью фактическим применением |
|
||||
| T6 → T2..T5 | финальные проверки | Чисто |
|
||||
|
||||
## Task status
|
||||
|
||||
- Task 6: complete (review pending). Отчёт: task-6-report.md.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Task 2 — Персистентность: системный и tenant-контексты, миграции. Отчёт
|
||||
|
||||
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`.
|
||||
|
||||
## Итог
|
||||
|
||||
Статус: **DONE_WITH_CONCERNS** (см. «Отклонения»). Сборка 0 warnings/0 errors, тесты 6 PASS,
|
||||
dev-БД пересоздана (public: tenants, users, sessions, `__EFMigrationsHistory` c одной строкой
|
||||
`InitialSystem`), tenant-миграция `InitialTenant` создана, SQL без схемы, не применялась
|
||||
(применение — Task 5).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы
|
||||
- `src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs` — POCO пользователя
|
||||
(Id = `Guid.NewGuid()` на клиенте, Login, TenantId, PasswordHash, Status = "active", CreatedAt).
|
||||
- `src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs` — POCO сессии
|
||||
(TokenHash — PK, UserId, Login-денормализация, ExpiresAt, CreatedAt).
|
||||
- `src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs` — POCO настройки
|
||||
тенанта (Key — PK, ValueJson, UpdatedAt). 1 тип = 1 файл.
|
||||
- `src/core/Deal.Infrastructure/Persistence/UserConfiguration.cs` — `ToTable("users","public")`,
|
||||
PK Id, уникальный индекс Login, индекс TenantId, `PasswordHash` text, `Status` default "active",
|
||||
`CreatedAt` default `now()` (SQL), FK `users.TenantId → tenants.Id` ON DELETE RESTRICT.
|
||||
- `src/core/Deal.Infrastructure/Persistence/SessionConfiguration.cs` — `ToTable("sessions","public")`,
|
||||
PK TokenHash (varchar(64)), индексы UserId и ExpiresAt, MaxLength/IsRequired,
|
||||
FK `sessions.UserId → users.Id` ON DELETE CASCADE.
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantSettingConfiguration.cs` — `ToTable("settings")`
|
||||
БЕЗ схемы (модель бессхемная, живёт через search_path), PK Key (varchar(200)),
|
||||
`ValueJson` text NOT NULL, `UpdatedAt` required.
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantConfiguration.cs` — вынес конфигурацию
|
||||
`TenantEntity` из `DealDbContext.OnModelCreating` для единообразия; маппинг НЕ изменён
|
||||
(ToTable "tenants","public", PK Id, Name varchar(200) NOT NULL) — в рамках опции задачи «по желанию».
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs` — бессхемный DbContext тенанта;
|
||||
DbSet `Settings`; применяет только `TenantSettingConfiguration`.
|
||||
- `src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs` — design-time фабрика
|
||||
для dotnet-ef; та же строка подключения (env `DEAL_PG_CONNECTION` или localhost:5433);
|
||||
`.UseNpgsql(cs, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))` (без схемы).
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs` — добавлены DbSet `Users`, `Sessions`;
|
||||
`OnModelCreating` применяет `TenantConfiguration/UserConfiguration/SessionConfiguration`
|
||||
(по одной на конфигурацию, без assembly-скана — чтобы не затащить `settings` в системную модель).
|
||||
|
||||
### Удалены
|
||||
- `Migrations/20260905190044_InitialPublic.cs`, `...Designer.cs`, `Migrations/DealDbContextModelSnapshot.cs`
|
||||
(пересозданы начисто по Ruling 4).
|
||||
|
||||
### Не менялись
|
||||
- `TenantEntity.cs`, `Data/TenantContext.cs`, `Data/ConnectionStringProvider.cs`,
|
||||
`Migrations/TenantSchemaMigrator.cs`, `Deal.Api/Program.cs` (регистрации как были;
|
||||
`TenantDbContext` в DI НЕ регистрировался — появится в Task 5), конфиги/тесты.
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
Строка подключения: `Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password`
|
||||
(env `DEAL_PG_CONNECTION` не задан — используется fallback фабрик).
|
||||
|
||||
### 1. Сброс схемы dev-БД (Ruling 4)
|
||||
```
|
||||
$ docker exec deal-postgres psql -U deal -d deal -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
NOTICE: drop cascades to 2 other objects
|
||||
DETAIL: drop cascades to table "__EFMigrationsHistory"
|
||||
drop cascades to table tenants
|
||||
DROP SCHEMA
|
||||
CREATE SCHEMA
|
||||
```
|
||||
|
||||
### 2. Удаление старых артефактов миграций
|
||||
Удалены `20260905190044_InitialPublic.cs`, `20260905190044_InitialPublic.Designer.cs`,
|
||||
`DealDbContextModelSnapshot.cs` из `Deal.Infrastructure/Migrations/`.
|
||||
|
||||
### 3. Создание миграций (из `src/core`)
|
||||
```
|
||||
$ dotnet ef migrations add InitialSystem --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
|
||||
Build started...
|
||||
Build succeeded.
|
||||
Done. To undo this action, use 'ef migrations remove'
|
||||
|
||||
$ dotnet ef migrations add InitialTenant --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext
|
||||
Build started...
|
||||
Build succeeded.
|
||||
Done. To undo this action, use 'ef migrations remove'
|
||||
```
|
||||
|
||||
Появились:
|
||||
- `Migrations/20260905192825_InitialSystem.cs` (+ `.Designer.cs`) — tenants+users+sessions в `public`;
|
||||
- `Migrations/DealDbContextModelSnapshot.cs`;
|
||||
- `Migrations/TenantDb/20260905193010_InitialTenant.cs` (+ `.Designer.cs`) — `settings`;
|
||||
- `Migrations/TenantDb/TenantDbContextModelSnapshot.cs`.
|
||||
(см. «Отклонения» — папка `TenantDb`, а не корень `Migrations/`.)
|
||||
|
||||
### 4. Применение системной миграции
|
||||
```
|
||||
$ dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
|
||||
Build started...
|
||||
Build succeeded.
|
||||
Failed executing DbCommand (19ms) ... SELECT "MigrationId", "ProductVersion" FROM "__EFMigrationsHistory" ...
|
||||
Acquiring an exclusive lock for migration application. ...
|
||||
Applying migration '20260905192825_InitialSystem'.
|
||||
Done.
|
||||
```
|
||||
«Failed executing DbCommand» — ожидаемое штатное зондирование отсутствующей (после DROP SCHEMA)
|
||||
таблицы истории перед применением; миграция применена успешно.
|
||||
|
||||
### 5. Проверка `InitialTenant`
|
||||
В файле миграции (`Migrations/TenantDb/20260905193010_InitialTenant.cs`) — только
|
||||
`CreateTable(name: "settings", ...)`, БЕЗ параметра `schema`; grep по `schema|public|tenant_`
|
||||
в трёх файлах tenant-миграции даёт 0 совпадений в SQL (единственное совпадение — ключевое слово
|
||||
C# `public` в `public partial class`). История миграций НЕ создаётся телом миграции —
|
||||
EF создаёт `__TenantMigrationsHistory` при применении (`Migrate`), что соответствует Ruling 3
|
||||
(в Task 5 таблица истории создастся в схеме тенанта). Миграция не применялась.
|
||||
|
||||
## Проверки (Acceptance)
|
||||
|
||||
### Build
|
||||
```
|
||||
$ dotnet build Deal.sln --nologo
|
||||
Сборка успешно выполнено через 7,0 с # и повторно: 1,9 с
|
||||
```
|
||||
0 warnings / 0 errors (TreatWarningsAsErrors, AnalysisLevel latest).
|
||||
|
||||
### Tests
|
||||
```
|
||||
$ dotnet test tests/Deal.Tests.Unit --nologo
|
||||
Сводка теста: всего: 6; сбой: 0; успешно: 6; пропущено: 0
|
||||
```
|
||||
|
||||
### `dotnet ef migrations list` (из `src/core`)
|
||||
Без `--context` команда завершается ошибкой:
|
||||
```
|
||||
More than one DbContext was found. Specify which one to use. Use the '-Context' parameter for
|
||||
PowerShell commands and the '--context' parameter for dotnet commands.
|
||||
```
|
||||
По контекстам:
|
||||
```
|
||||
$ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context DealDbContext
|
||||
20260905192825_InitialSystem
|
||||
|
||||
$ dotnet ef migrations list --project Deal.Infrastructure --startup-project Deal.Api --context TenantDbContext
|
||||
Failed executing DbCommand ... SELECT "MigrationId", "ProductVersion" FROM "__TenantMigrationsHistory" ...
|
||||
20260905193010_InitialTenant (Pending)
|
||||
```
|
||||
`InitialSystem` — применена (без пометки Pending), `InitialTenant` — Pending
|
||||
(таблицы истории в БД нет — это норма: она создаётся при применении в Task 5).
|
||||
|
||||
### psql
|
||||
```
|
||||
$ docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"
|
||||
List of relations
|
||||
Schema | Name | Type | Owner
|
||||
--------+-----------------------+-------+-------
|
||||
public | __EFMigrationsHistory | table | deal
|
||||
public | sessions | table | deal
|
||||
public | tenants | table | deal
|
||||
public | users | table | deal
|
||||
(4 rows)
|
||||
|
||||
$ docker exec deal-postgres psql -U deal -d deal -c "SELECT \"MigrationId\" FROM public.\"__EFMigrationsHistory\";"
|
||||
20260905192825_InitialSystem # ровно одна строка
|
||||
|
||||
$ \d public.users
|
||||
Id | uuid | not null
|
||||
Login | character varying(200) | not null
|
||||
TenantId | uuid | not null
|
||||
PasswordHash | text | not null
|
||||
Status | text | not null | 'active'::text
|
||||
CreatedAt | timestamp with time zone | not null | now()
|
||||
Indexes: PK_users (Id); IX_users_Login UNIQUE (Login); IX_users_TenantId (TenantId)
|
||||
FK: FK_users_tenants_TenantId → tenants(Id) ON DELETE RESTRICT
|
||||
(Referenced by) FK_sessions_users_UserId → users(Id) ON DELETE CASCADE
|
||||
|
||||
$ \d public.sessions
|
||||
TokenHash | character varying(64) | not null
|
||||
UserId | uuid | not null
|
||||
Login | character varying(200) | not null
|
||||
ExpiresAt | timestamp with time zone | not null
|
||||
CreatedAt | timestamp with time zone | not null
|
||||
Indexes: PK_sessions (TokenHash); IX_sessions_ExpiresAt (ExpiresAt); IX_sessions_UserId (UserId)
|
||||
FK: FK_sessions_users_UserId → users(Id) ON DELETE CASCADE
|
||||
```
|
||||
Колонки/индексы соответствуют конфигурациям. `public.tenants` — без изменений по сравнению со
|
||||
скэффолдом (та же схема, что была у `InitialPublic`).
|
||||
|
||||
### Стиль
|
||||
1 тип = 1 файл; XML-doc на public-типах (и на неочевидных свойствах: Login у сессии,
|
||||
TokenHash и т.п.); комментарии на русском; явные модификаторы; регионов нет; именованные
|
||||
константы вместо «магических» длин (`LoginMaxLength = 200`, `TokenHashMaxLength = 64`,
|
||||
`KeyMaxLength = 200`, `NameMaxLength = 200`).
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **Расположение tenant-миграции (основное).** `dotnet ef migrations add InitialTenant`
|
||||
(без `--output-dir`, как требует план) НЕ положил файлы в общую папку `Migrations/`,
|
||||
а молча создал подпапку `Migrations/TenantDb/` с namespace `Deal.Infrastructure.Migrations.TenantDb`.
|
||||
Поведение детерминированное (проверено: удалил папку и повторил команду — результат тот же,
|
||||
новый timestamp `20260905193010`). Ошибки или запроса `--output-dir` не было; имена классов
|
||||
(`InitialTenant`, `TenantDbContextModelSnapshot`) действительно не конфликтуют, но инструмент
|
||||
всё равно изолирует второй контекст в подпапку. Функционально ни на что не влияет: EF выбирает
|
||||
миграции контекста по атрибуту `[DbContext(...)]` в assembly, а не по namespace; `Database.Migrate()`
|
||||
в Task 5 найдёт `InitialTenant` и создаст таблицу истории в схеме тенанта. По инструкции задачи
|
||||
(«не придумывайте обходы сами») файлы НЕ переносил и namespace вручную не правил. Если ревьюеру
|
||||
критично именно расположение в корне `Migrations/` — можно пересоздать через
|
||||
`--output-dir Migrations`, но я осознанно оставил детерминированный вывод инструмента.
|
||||
2. **Ожидание `CREATE TABLE "__TenantMigrationsHistory"` в теле миграции.** План (проверка 5)
|
||||
предполагал в `InitialTenant` два `CreateTable`: `settings` и историю. По факту тело миграции
|
||||
содержит только `CREATE TABLE "settings"` — таблица истории миграций в EF создаётся
|
||||
инфраструктурой при применении (`Migrate`/`database update`), а не телом миграции.
|
||||
Требуемое «нет упоминаний схемы» выполнено (0 совпадений). Применение tenant-миграции
|
||||
не выполнялось (по плану это Task 5), поэтому фактическое создание `__TenantMigrationsHistory`
|
||||
в схеме тенанта будет проверено в Task 5 (см. `progress.md`, строка T2→T5).
|
||||
3. **`migrations list` без `--context`.** После появления второго DbContext команда без `--context`
|
||||
падает с «More than one DbContext was found...». Обе миграции видны при запуске по контекстам
|
||||
(см. выше) — это и есть содержимое Acceptance 6; в отчёте зафиксирован требуемый флаг.
|
||||
4. **Колонки/типы `settings`.** Задача (код-уровень) задаёт `Key` с MaxLength 200 и `ValueJson` как
|
||||
`text`; Ruling 9 в SQL-нотации описывает `key text PK`, `value_json`, `updated_at`. Реализовано
|
||||
по кодовой спецификации задачи и в конвенции кодовой базы этапа (колонки PascalCase — как у
|
||||
существующей `public.tenants` из скэффолда, без naming-convention пакета): колонки
|
||||
`Key` varchar(200) PK, `ValueJson` text NOT NULL, `UpdatedAt` timestamptz NOT NULL.
|
||||
Ровно те же соглашения применены к `users`/`sessions` (колонки = имена свойств).
|
||||
5. **Семантика FK.** В конфигурациях созданы реальные внешние ключи (в задаче они заявлены как
|
||||
«FK → ...»): `users.TenantId → tenants.Id` ON DELETE RESTRICT (защита от случайного каскадного
|
||||
сноса пользователей при удалении тенанта; удаления тенантов в этапе нет), `sessions.UserId →
|
||||
users.Id` ON DELETE CASCADE (сессии — транзитивные данные пользователя). Поведение в этапе 1
|
||||
ничем не упражняется — зафиксировано для Task 3+.
|
||||
|
||||
## Прочее
|
||||
- `TenantDbContext` в DI не регистрировался (только класс + design-time фабрика) — по задаче.
|
||||
- `Deal.Api/Program.cs` не менялся; для сборки дополнительные `using` не понадобились.
|
||||
- `DealDbDesignTimeFactory.cs` — без изменений (уже соответствует требованию «как сейчас»).
|
||||
- Другие модули/фронтенд/`backend` не тронуты; `.editorconfig`/`Directory.Build.props` не менялись.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Task 3 — Модуль Tenants: прикладные сервисы аутентификации и тенантов. Отчёт
|
||||
|
||||
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`.
|
||||
Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS (6 старых + 19 новых, требуется ≥6).
|
||||
|
||||
## Итог
|
||||
|
||||
Реализован модуль `Deal.Modules.Tenants` по паттерну «port & adapter» (Ruling 1): прикладные
|
||||
сервисы аутентификации (`AuthService`) и реестра тенантов (`TenantService`), порты
|
||||
`IPasswordHasher`/`IAuthStore`/`ITenantRepository`/`ITenantProvisioner`, record-DTO
|
||||
(`Application/Models`). Модуль НЕ содержит EF и НЕ ссылается на Infrastructure (проверено grep).
|
||||
EF-адаптеры (`AuthStore`, `TenantRepository`) добавлены в `Deal.Infrastructure`
|
||||
(+ ProjectReference на модуль, циклов нет). Хэш пароля — Argon2id пакетом
|
||||
`Isopoh.Cryptography.Argon2` 2.0.0 через `Argon2.Hash/Verify` (Ruling 5); токен сессии —
|
||||
32 байта Base64Url, в БД — SHA-256 hex (Ruling 6); сессия 30 дней; смена пароля инвалидирует
|
||||
все сессии и выдаёт свежую (семантика `backend/app/auth.py` + `auth_routes.change`).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — `src/core/Deal.Modules.Tenants/Application/` (namespace `Deal.Modules.Tenants.Application.*`)
|
||||
- `IPasswordHasher.cs` — порт: `Hash(password)` → encoded-строка; `Verify(password, encoded)`.
|
||||
- `DefaultPasswordHasher.cs` — Argon2id (Ruling 5). Вызовы `Argon2.Hash(password)` /
|
||||
`Argon2.Verify(encoded, password)` — это дефолты библиотеки 2.0.0: Argon2id
|
||||
(`Argon2Type.HybridAddressing` — подтверждено исходником v2.0.0), соль 16 случайных байт,
|
||||
t=3, m=65536 (64 MiB), p=1, длина хэша 32 байта. Комментарий про дефолты — в XML-doc.
|
||||
- `SessionTokens.cs` — `NewToken()` (32 байта `RandomNumberGenerator` → Base64Url без padding,
|
||||
43 символа) и `HashToken(raw)` (SHA-256 hex, 64 символа).
|
||||
- `Application/Models/StoredUserDto.cs`, `UserIdentityDto.cs`, `SessionDto.cs`,
|
||||
`TenantRecordDto.cs`, `LoginResultDto.cs`, `ChangePasswordResultDto.cs` — record, 1 тип = 1 файл;
|
||||
коды ошибок смены пароля — public-константы на `ChangePasswordResultDto`
|
||||
(`ErrorOldPassword = "oldPassword"`, `ErrorTooShort = "tooShort"`).
|
||||
- `IAuthStore.cs` — порт хранилища: 8 async-методов с `CancellationToken` (поиск по логину/токену/id,
|
||||
create/delete сессий, update хэша, очистка протухших). Модификаторы интерфейса явные (`public`),
|
||||
как требует `.editorconfig` (IDE0040) и существующий `ITenantContext`.
|
||||
- `AuthService.cs` — login/logout/changePassword/resolveSession + private helper
|
||||
`CreateSessionForUserAsync`. Логин нормализуется `ToLowerInvariant().Trim()`. Бизнес-отказы —
|
||||
null/коды в DTO, исключений не бросает. `SessionLifetimeDays = 30`, `MinNewPasswordLength = 4`.
|
||||
resolveSession проверяет `ExpiresAt > UtcNow` и всегда вызывает `DeleteExpiredSessionsAsync`.
|
||||
- `ITenantRepository.cs` — `FindByIdAsync/CreateAsync/ListAsync`.
|
||||
- `ITenantProvisioner.cs` — `ProvisionAsync(TenantId, ct)` (реализация — Task 5).
|
||||
- `TenantService.cs` — `CreateTenantAsync` (Guid `"N"`, status "active", CreatedAt=UtcNow →
|
||||
репозиторий → `ITenantProvisioner.ProvisionAsync`) и `ListTenantsAsync`.
|
||||
- `TenantModuleRegistrar.cs` — `AddTenantsModule(this IServiceCollection)`: singleton
|
||||
`IPasswordHasher → DefaultPasswordHasher`, scoped `AuthService`/`TenantService`; адаптеры
|
||||
`IAuthStore`/`ITenantRepository`/`ITenantProvisioner` НЕ регистрируются (комментарий-обоснование
|
||||
времён жизни в XML-doc).
|
||||
|
||||
### Созданы — `src/core/Deal.Infrastructure/Persistence/Repositories/`
|
||||
- `AuthStore.cs` — EF-адаптер `IAuthStore` на `DealDbContext` (public.users/sessions):
|
||||
`FindSessionByTokenHashAsync` учитывает `ExpiresAt > UtcNow`; delete/update — через
|
||||
`ExecuteDeleteAsync`/`ExecuteUpdateAsync` (без трекинга); `DeleteExpiredSessionsAsync`
|
||||
удаляет `ExpiresAt <= UtcNow`. Маппинг DTO↔сущности вручную.
|
||||
- `TenantRepository.cs` — EF-адаптер `ITenantRepository` (public.tenants), список упорядочен
|
||||
по `CreatedAt`.
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj` — добавлены PackageReference:
|
||||
`Isopoh.Cryptography.Argon2` 2.0.0, `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11.
|
||||
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — добавлен ProjectReference на
|
||||
`..\Deal.Modules.Tenants\Deal.Modules.Tenants.csproj` (циклов нет).
|
||||
|
||||
### Созданы — `src/core/tests/Deal.Tests.Unit/`
|
||||
- `PasswordHasherTests.cs` (4 теста), `SessionTokensTests.cs` (4), `AuthServiceTests.cs` (11),
|
||||
fakes: `FakeAuthStore.cs`, `FakePasswordHasher.cs` (по 1 типу в файле). Fake-хранилище
|
||||
НЕ фильтрует протухшие сессии при поиске — так проверяется, что сервис сам учитывает `ExpiresAt`.
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
```
|
||||
$ dotnet build Deal.sln --nologo
|
||||
Сборка успешно выполнено через 2,9 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
|
||||
|
||||
$ dotnet test tests/Deal.Tests.Unit --nologo --no-build
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с
|
||||
```
|
||||
|
||||
Grep-проверка изоляции модуля (по `src/core/Deal.Modules.Tenants/`):
|
||||
`Deal.Infrastructure|Microsoft.EntityFrameworkCore|Npgsql` → 0 совпадений;
|
||||
`using Deal.Infrastructure|using Microsoft.EntityFrameworkCore|using Npgsql` → 0 совпадений.
|
||||
|
||||
## Список тестов (новые, 19)
|
||||
|
||||
PasswordHasherTests:
|
||||
1. `Hash_ReturnsArgon2idEncodedStringDifferentFromPassword` — encoded ≠ пароль, префикс `$argon2id$`.
|
||||
2. `Verify_WithCorrectPassword_ReturnsTrue`.
|
||||
3. `Verify_WithWrongPassword_ReturnsFalse`.
|
||||
4. `Hash_SamePasswordTwice_DifferentHashesBecauseOfRandomSalt`.
|
||||
|
||||
SessionTokensTests:
|
||||
5. `NewToken_ReturnsUniqueLongEnoughTokens`.
|
||||
6. `NewToken_UsesBase64UrlAlphabetWithoutPadding` — 43 символа, без `= + /`.
|
||||
7. `HashToken_IsDeterministicHexSha256` — 64 hex, детерминирован.
|
||||
8. `HashToken_DifferentTokensProduceDifferentHashes`.
|
||||
|
||||
AuthServiceTests (fake IAuthStore + fake хэшер):
|
||||
9. `LoginAsync_WithValidCredentials_ReturnsTokenAndCreatesSession` — логин нормализуется
|
||||
(" Admin " → "admin"), сессия создана с SHA-256-хэшем токена и ExpiresAt в будущем.
|
||||
10. `LoginAsync_WithWrongPassword_ReturnsNullLoginAndToken`.
|
||||
11. `LoginAsync_WithUnknownLogin_ReturnsNullLoginAndToken`.
|
||||
12. `ChangePasswordAsync_WithWrongOldPassword_ReturnsOldPasswordError` — `"oldPassword"`, вызовов нет.
|
||||
13. `ChangePasswordAsync_WithShortNewPassword_ReturnsTooShortError` — `"tooShort"` (новый пароль "123").
|
||||
14. `ChangePasswordAsync_Success_InvalidatesOldSessionsAndCreatesFreshOne` — журнал вызовов
|
||||
ровно `delete-user-sessions → update-password-hash → create-session`; в хранилище одна новая
|
||||
сессия; старый raw-токен не резолвится, новый — резолвится на того же пользователя.
|
||||
15. `ResolveSessionAsync_WithExpiredSession_ReturnsNull` — протухшая сессия (fake вернул её) → null;
|
||||
`DeleteExpiredSessionsAsync` вызвана, сессия удалена.
|
||||
16. `ResolveSessionAsync_WithValidSession_ReturnsUser`.
|
||||
17. `ResolveSessionAsync_WithoutToken_ReturnsNull` (null и пробелы, вызовов нет).
|
||||
18. `LogoutAsync_WithToken_DeletesSession`.
|
||||
19. `LogoutAsync_WithoutToken_IsNoOp`.
|
||||
|
||||
Старые 6 тестов (Marker/TenantId/TenantEntity/TenantSchemaMigrator) — без изменений, PASS.
|
||||
|
||||
## Проверки (Acceptance)
|
||||
|
||||
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS (6 старых + 19 новых, ≥6). ✅
|
||||
3. Стиль: 1 тип = 1 файл; XML-doc на public-типах и членах интерфейсов; явные модификаторы
|
||||
(в т.ч. `public` у членов интерфейсов — IDE0040); без магических чисел (именованные константы
|
||||
`SessionLifetimeDays`, `MinNewPasswordLength`, `RawTokenByteLength`, `ActiveStatus`, коды ошибок);
|
||||
комментарии на русском. ✅
|
||||
4. В модуле НЕТ using/ссылок на Deal.Infrastructure и EF — подтверждено grep. ✅
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **Версия Isopoh зафиксирована 2.0.0** (latest stable, поставлена `dotnet add package`). API
|
||||
подтверждён по исходникам тега v2.0.0 и README пакета: требуемые задачей вызовы
|
||||
`Argon2.Hash(password)` / `Argon2.Verify(encoded, password)` существуют; по умолчанию вариант
|
||||
`HybridAddressing` (Argon2id, префикс encoded-строки `$argon2id$`), соль 16 байт, t=3, m=64 MiB,
|
||||
p=1. Требование «Argon2id, дефолты библиотеки» выполнено ровно так, как описано в задаче.
|
||||
2. **DI-регистрация адаптеров** (`IAuthStore`, `ITenantRepository`, `ITenantProvisioner`) в Task 3
|
||||
НЕ выполнялась: инструкция задачи (п. «Ключевые решения», файл 11–12) явно откладывает её в
|
||||
Deal.Api (Task 4/5), тогда как общая строка плана [L85] упоминала `ServiceCollectionExtensions.cs`
|
||||
в Infrastructure (файла в проекте нет). Следовал детальной спецификации задачи — регистрация
|
||||
адаптеров будет в `Deal.Api/Program.cs` в Task 4.
|
||||
3. **`TenantModuleRegistrar`** расположен в `Application/` (namespace `Deal.Modules.Tenants.Application`)
|
||||
— по списку файлов задачи (п. 11); в Task 4 достаточно `using Deal.Modules.Tenants.Application`.
|
||||
4. **`FindSessionByTokenHashAsync`** (adapter) дополнительно фильтрует по `ExpiresAt` (требование
|
||||
п. 12), а `AuthService.ResolveSessionAsync` независимо проверяет `ExpiresAt > now` (требование
|
||||
п. 6) — проверка в сервисе необходима для корректной работы с любым хранилищем и покрыта тестом 15.
|
||||
5. Стоимость Argon2 по дефолту библиотеки — 64 MiB памяти на хэш; тестовый набор целиком
|
||||
укладывается в ~5 с, ничего не переопределял (по заданию — дефолты).
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 4 curl-приёмка auth-эндпоинтов Deal.Api на :5080 (см. план Task 4, п.7).
|
||||
# Сценарий: health → login admin/admin (кука в jar-old) → me → неверный пароль (401) →
|
||||
# change-password (admin→admin2, новая кука в jar-new) → logout старой сессии → проверки
|
||||
# me/logins → возврат пароля admin. Вывод каждой команды печатается в stdout.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
CORE_DIR="C:/telbase/src/core"
|
||||
API_DIR="$CORE_DIR/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR_OLD="/tmp/task4-jar-old.txt"
|
||||
JAR_NEW="/tmp/task4-jar-new.txt"
|
||||
|
||||
rm -f "$JAR_OLD" "$JAR_NEW" /tmp/task4-api.log
|
||||
|
||||
echo "== 0. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > /tmp/task4-api.log 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== Завершение: останавливаем сервер (pid $APP_PID) =="
|
||||
kill "$APP_PID" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
sleep 10
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/health =="
|
||||
curl -s "$BASE_URL/api/health"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login {admin,admin} — ожидаем 200 {ok,login} + Set-Cookie (httpOnly) =="
|
||||
curl -s -i -c "$JAR_OLD" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_OLD" "$BASE_URL/api/auth/me"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 4. POST /api/auth/login {admin,wrong} — ожидаем 401 {detail} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"wrong"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 5. POST /api/auth/change-password {admin → admin2} (кука jar-old) — ожидаем 200 {ok} + новая кука в jar-new =="
|
||||
curl -s -i -c "$JAR_NEW" -b "$JAR_OLD" -X POST "$BASE_URL/api/auth/change-password" \
|
||||
-H "Content-Type: application/json" -d '{"oldPassword":"admin","newPassword":"admin2"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 6. POST /api/auth/logout старой сессией (jar-old) — ожидаем 200 {ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/logout" -b "$JAR_OLD"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 7. GET /api/auth/me с jar-old — ожидаем 401 {detail: Требуется авторизация} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_OLD" "$BASE_URL/api/auth/me"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 8. GET /api/auth/me с jar-new — ожидаем 200 {login,ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_NEW" "$BASE_URL/api/auth/me"
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 9. login admin/admin — ожидаем 401 =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 10. login admin/admin2 — ожидаем 200 {ok,login} (кука в jar-old) =="
|
||||
curl -s -c "$JAR_OLD" -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin2"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 11. Возврат пароля: change-password {admin2 → admin} (кука jar-new) — ожидаем 200 {ok} =="
|
||||
curl -s -i -c "$JAR_NEW" -b "$JAR_NEW" -X POST "$BASE_URL/api/auth/change-password" \
|
||||
-H "Content-Type: application/json" -d '{"oldPassword":"admin2","newPassword":"admin"}'
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "== 12. Финальная проверка: me с jar-new — ожидаем 200 {login,ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR_NEW" "$BASE_URL/api/auth/me"
|
||||
echo
|
||||
@@ -0,0 +1,132 @@
|
||||
# Task 4 — Эндпоинты auth, middleware сессии, DI, curl-приёмка. Отчёт
|
||||
|
||||
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md` (Task 4, Rulings 6/7/7a/8/10).
|
||||
Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS; curl-приёмка на :5080 проходит целиком (два прогона подряд — второй подтверждает идемпотентность seed).
|
||||
|
||||
## Итог
|
||||
|
||||
`Deal.Api` получил полный HTTP-контракт auth 1:1 с прототипом (`auth_routes.py`): login/logout/me/change-password
|
||||
на группе `/api/auth`, httpOnly-кука `deal_session` (Ruling 6), пайплайн `CORS → SessionMiddleware → эндпоинты`,
|
||||
DI модуля и адаптеров, минимальный seed (Ruling 8). `SessionMiddleware` разрешает сессию по куке, кладёт
|
||||
`CurrentUser` в `HttpContext.Items` и выставляет tenant-контекст запроса; сама 401 не отвечает (pass-through).
|
||||
В `ITenantContext` добавлены `SetTenant`/`Reset` (сброс в finally после запроса).
|
||||
|
||||
## Файлы
|
||||
|
||||
### Созданы — `src/core/Deal.Api/`
|
||||
- `Configuration/CookieOptions.cs` — настройки куки (секция `Cookies`): `Name="deal_session"`, `Days`, `Secure`.
|
||||
`Days` намеренно без код-дефолта: единственный источник «30» — конфигурация (см. XML-doc; константа
|
||||
`AuthService.SessionLifetimeDays` переедет в конфиг в Task 5).
|
||||
- `Http/CurrentUser.cs` — `sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status)`.
|
||||
- `Http/AuthHelpers.cs` — ключ `HttpContext.Items["CurrentUser"]` (public const), `SetCurrentUser`,
|
||||
`GetCurrentUser` (+ const сообщения 401). Сделано прагматично: эндпоинты сами проверяют null и отдают 401
|
||||
(tuple-хелпер `RequireUser` из формулировки задачи не понадобился — точек использования всего две).
|
||||
- `Middleware/SessionMiddleware.cs` — singleton; опции через `IOptionsMonitor<CookieOptions>`; на запрос —
|
||||
scope через `RequestServices` → `AuthService.ResolveSessionAsync`; при пользователе: `SetCurrentUser` +
|
||||
`_tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N")))`; `finally → Reset()`. Pass-through без 401.
|
||||
- `Endpoints/AuthEndpoints.cs` — `MapAuthEndpoints(this IEndpointRouteBuilder)`, группа `/api/auth`
|
||||
с тегом OpenAPI `auth`; логика ответов и сообщений по прототипу; выставление куки (httpOnly, SameSite=Lax,
|
||||
Path=/, MaxAge=`TimeSpan.FromDays(options.Days)`, Secure из конфига); удаление куки на logout.
|
||||
- `Endpoints/LoginRequest.cs`, `Endpoints/ChangePasswordRequest.cs` — record-тела (входящий JSON camelCase,
|
||||
System.Text.Json case-insensitive по умолчанию).
|
||||
- `Hosting/StartupSeed.cs` — минимальный seed (Ruling 8): `EnsureSeedAsync(sp, ct)`. Через scope: пуст ли
|
||||
`ITenantRepository.ListAsync` → создать тенанта `Default`/`active`; пользователь `admin` из env
|
||||
`DEAL_BOOTSTRAP_LOGIN/PASSWORD` (default admin/admin), хэш `IPasswordHasher`; идемпотентно (если
|
||||
пользователь есть — no-op). Провижинер не используется.
|
||||
- `Hosting/PendingTenantProvisioner.cs` — см. Отклонение 1.
|
||||
|
||||
### Изменены
|
||||
- `src/core/Deal.Api/Program.cs` — `AddTenantsModule()` + `AddDealPersistence()`, временная регистрация
|
||||
`ITenantProvisioner` (Отклонение 1), `Configure<CookieOptions>(GetSection("Cookies"))`,
|
||||
`ConfigureHttpJsonOptions` (UnsafeRelaxedJsonEscaping — Отклонение 3), dev-CORS
|
||||
(`SetIsOriginAllowed(_ => true).AllowCredentials().AllowAnyHeader().AllowAnyMethod()` + комментарий про
|
||||
Cloudflare/прод), `UseCors` → `UseMiddleware<SessionMiddleware>` → `MapAuthEndpoints()`,
|
||||
`await StartupSeed.EnsureSeedAsync(...)` перед `Run()`. `public partial class Program` сохранён.
|
||||
- `src/core/Deal.Api/appsettings.json` + `appsettings.Development.json` — секция `Cookies`
|
||||
(Name/Days/Secure); ConnectionStrings в dev-файле не тронуты (Отклонение 2).
|
||||
- `src/core/Deal.Api/Deal.Api.csproj` — явный ProjectReference на `Deal.Modules.Tenants` (композиционный
|
||||
корень регистрирует модуль напрямую; до этого модуль был доступен только транзитивно через Infrastructure).
|
||||
- `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs` — в интерфейс добавлены `SetTenant(TenantId)` и
|
||||
`Reset()` (SetTenant был только на классе-реализации; middleware ходит через интерфейс).
|
||||
- `src/core/Deal.Infrastructure/Data/TenantContext.cs` — реализация `Reset()` (обнуляет AsyncLocal).
|
||||
|
||||
### Создан — `src/core/Deal.Infrastructure/`
|
||||
- `ServiceCollectionExtensions.cs` — `AddDealPersistence(this IServiceCollection)`: scoped
|
||||
`IAuthStore → AuthStore`, `ITenantRepository → TenantRepository` (порт&адаптер; XML-doc про времена жизни).
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
### `dotnet build Deal.sln --nologo`
|
||||
```
|
||||
Сборка успешно выполнено через 2,1 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
|
||||
```
|
||||
|
||||
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
|
||||
```
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,8 с
|
||||
```
|
||||
|
||||
### curl-приёмка (`sh .superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`, :5080)
|
||||
Скрипт: `ASPNETCORE_ENVIRONMENT=Development` + apphost `Deal.Api.exe --urls http://localhost:5080`,
|
||||
jar-файлы `/tmp/task4-jar-{old,new}.txt`, kill по завершении (trap EXIT). Выводы по шагам (1-й прогон):
|
||||
|
||||
```
|
||||
1. health → {"ok":true,"service":"deal"}
|
||||
2. login admin/admin (jar-old)
|
||||
Set-Cookie: deal_session=ndz84oBA55R8uM2bEcGI0orKB4fJ8jezKVcDcrGHf6M; max-age=2592000; path=/; samesite=lax; httponly
|
||||
→ 200 {"ok":true,"login":"admin"}
|
||||
3. me (jar-old) → 200 {"login":"admin","ok":true}
|
||||
4. login admin/wrong → 401 {"detail":"Неверный логин или пароль"}
|
||||
5. change-password admin→admin2 (jar-old, новая кука в jar-new)
|
||||
Set-Cookie: deal_session=jPJbNRfO_jh3WaOevTEhYSRlKV8gDD6llW7sIJxLzCk; ... httponly
|
||||
→ 200 {"ok":true}
|
||||
6. logout (jar-old) → 200 {"ok":true}
|
||||
7. me (jar-old) → 401 {"detail":"Требуется авторизация"}
|
||||
8. me (jar-new) → 200 {"login":"admin","ok":true}
|
||||
9. login admin/admin → 401 {"detail":"Неверный логин или пароль"}
|
||||
10. login admin/admin2 (jar-old) → 200 {"ok":true,"login":"admin"}
|
||||
11. change-password admin2→admin (jar-new) → 200 {"ok":true} + свежая кука
|
||||
12. me (jar-new) → 200 {"login":"admin","ok":true}
|
||||
```
|
||||
Кука в Set-Cookie: `httpOnly`, `samesite=lax`, `max-age=2592000` (30 дней), `path=/`. Сервер останавливается
|
||||
скриптом (проверено: после прогона порт :5080 не отвечает). Второй прогон подряд — успешен (идемпотентный seed).
|
||||
|
||||
### psql (после приёмки)
|
||||
```
|
||||
tenants: b15066ee-126a-4a3b-9c80-1b36a526d33d | Default | active
|
||||
users: admin | active | $argon2id$v=...
|
||||
sessions: 1 (последняя свежая сессия после финального change-password)
|
||||
```
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **DI: scoped `TenantService` не собирается до Task 5** — on-build валидация DI в Development падает:
|
||||
`Unable to resolve service for type 'ITenantProvisioner' while attempting to activate 'TenantService'`
|
||||
(реализация провижинера — файл Task 5). Решение: временная заглушка `Deal.Api/Hosting/PendingTenantProvisioner.cs`
|
||||
(бросает `InvalidOperationException` при вызове; в Task 4 провижининг не вызывается), регистрация в
|
||||
`Program.cs` с комментарием «удалить в Task 5».
|
||||
2. **`Cookies` добавлена и в базовый `appsettings.json`** (задача упоминала только Development-файл): иначе при
|
||||
`ASPNETCORE_ENVIRONMENT != Development` `Days` остался бы 0 (cookie session-only). В Dev-файле секция
|
||||
продублирована по заданию; env-переменные `Cookies__*` перекрывают обе.
|
||||
3. **JSON-энкодер `UnsafeRelaxedJsonEscaping`**: без него System.Text.Json экранирует кириллицу (`\uXXXX`),
|
||||
а прототип FastAPI отдаёт raw UTF-8 (проверено в curl: `{"detail":"Неверный логин или пароль"}`).
|
||||
Фронт парсит оба варианта; выбран байт-1:1 с прототипом.
|
||||
4. **Seed создаёт пользователя через `DealDbContext` напрямую**, а не через `IAuthStore`: в порте нет операции
|
||||
создания пользователя (он только читает/обновляет). Расширять интерфейс модуля ради временного seed не стали —
|
||||
зафиксировано в XML-doc `StartupSeed`. Тенант создаётся через `ITenantRepository`, существование пользователя
|
||||
проверяется через `IAuthStore.FindUserByLoginAsync`.
|
||||
5. **Алиасы `using CookieOptions = Deal.Api.Configuration.CookieOptions`** в 3 файлах: имя совпадает с
|
||||
`Microsoft.AspNetCore.Http.CookieOptions` (неявный using Web SDK) → CS0104. В `AuthEndpoints` второй алиас —
|
||||
`AspNetCoreCookieOptions` для типа из `Microsoft.AspNetCore.Http`.
|
||||
6. Login в ответе нормализуется в нижний регистр (поведение AuthService из Task 3, осознанно строже прототипа,
|
||||
который возвращает `body.login.strip()` как есть). На приёмку не влияет (admin → admin).
|
||||
|
||||
## Проверки (Acceptance)
|
||||
|
||||
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅
|
||||
3. curl-цепочка (п.7) проходит; кука httpOnly в заголовке Set-Cookie. ✅
|
||||
4. Стиль: 1 тип = 1 файл; XML-doc на public; комментарии на русском; без магических строк/чисел
|
||||
(константы сообщений, имён env, префиксов). `.editorconfig`/`Directory.Build.props` и другие модули не тронуты. ✅
|
||||
|
||||
Скрипт приёмки оставлен: `.superpowers/sdd/deal-stage1-tenancy/task-4-curl-acceptance.sh`.
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 5 функциональная приёмка: bootstrap + провижининг схемы при старте (см. план Task 5, п.6).
|
||||
# Сценарий: чистый старт (seed тенанта+admin, схема tenant_000..001 с settings и историей,
|
||||
# login admin/admin) → повторный старт (идемпотентность, без дублей и ошибок). Проект НЕ git.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_EXE="C:/telbase/src/core/Deal.Api/bin/Debug/net10.0/Deal.Api.exe"
|
||||
PSQL="docker exec deal-postgres psql -U deal -d deal"
|
||||
LOG_RUN1="/tmp/task5-api-run1.log"
|
||||
LOG_RUN2="/tmp/task5-api-run2.log"
|
||||
|
||||
# Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов.
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
rm -f "$LOG_RUN1" "$LOG_RUN2"
|
||||
|
||||
echo "============================================================"
|
||||
echo "== RUN 1: чистый старт (база пуста) =="
|
||||
echo "============================================================"
|
||||
cd "C:/telbase/src/core/Deal.Api" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN1" 2>&1 &
|
||||
PID1=$!
|
||||
|
||||
echo "-- ожидание старта (15 c) --"
|
||||
sleep 15
|
||||
|
||||
echo
|
||||
echo "== 1a. tenants (ожидаем 1 строку с фикс. id) =="
|
||||
$PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;'
|
||||
|
||||
echo
|
||||
echo "== 1b. users (ожидаем admin) =="
|
||||
$PSQL -c 'SELECT "Login" FROM public.users;'
|
||||
|
||||
echo
|
||||
echo "== 1c. схемы (ожидаем tenant_000...001) =="
|
||||
$PSQL -c '\dn'
|
||||
|
||||
SCHEMA="tenant_00000000000000000000000000000001"
|
||||
echo
|
||||
echo "== 1d. таблицы в схеме тенанта (ожидаем settings + __TenantMigrationsHistory) =="
|
||||
$PSQL -c "\\dt $SCHEMA.*"
|
||||
|
||||
echo
|
||||
echo "== 1d2. история tenant-миграций (ожидаем InitialTenant) =="
|
||||
$PSQL -c "SELECT \"MigrationId\" FROM \"$SCHEMA\".\"__TenantMigrationsHistory\";"
|
||||
|
||||
echo
|
||||
echo "== 1e. login admin/admin =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
|
||||
echo
|
||||
echo "== хвост лога run1 (ошибки?) =="
|
||||
tail -n 8 "$LOG_RUN1"
|
||||
|
||||
echo
|
||||
echo "== остановка run1 (pid $PID1) =="
|
||||
kill "$PID1" 2>/dev/null
|
||||
sleep 2
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo "== RUN 2: повторный старт (идемпотентность) =="
|
||||
echo "============================================================"
|
||||
ASPNETCORE_ENVIRONMENT=Development "$API_EXE" --urls "$BASE_URL" > "$LOG_RUN2" 2>&1 &
|
||||
PID2=$!
|
||||
|
||||
echo "-- ожидание старта (12 c) --"
|
||||
sleep 12
|
||||
|
||||
echo
|
||||
echo "== 2a. tenants (ожидаем по-прежнему 1 строку) =="
|
||||
$PSQL -c 'SELECT "Id", "Name", "Status" FROM public.tenants;'
|
||||
|
||||
echo
|
||||
echo "== 2b. users (ожидаем по-прежнему admin, 1 строку) =="
|
||||
$PSQL -c 'SELECT "Login" FROM public.users;'
|
||||
|
||||
echo
|
||||
echo "== 2c. логин снова работает =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
|
||||
echo
|
||||
echo "== хвост лога run2 (ошибок быть не должно) =="
|
||||
tail -n 8 "$LOG_RUN2"
|
||||
|
||||
echo
|
||||
echo "== остановка run2 (pid $PID2) =="
|
||||
kill "$PID2" 2>/dev/null
|
||||
sleep 2
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
echo "== done =="
|
||||
@@ -0,0 +1,175 @@
|
||||
# Task 5 — Провижининг схем тенантов и bootstrap при старте. Отчёт
|
||||
|
||||
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`
|
||||
(Task 5, Rulings 3 и 8). Статус: **DONE**. Сборка 0 warnings/0 errors; тесты 25 PASS; функциональная приёмка
|
||||
на :5080 — два старта: первый создаёт seed и схему, повторный идемпотентен (дублей и ошибок нет), login admin/admin работает.
|
||||
|
||||
## Итог
|
||||
|
||||
Реализован механизм провижининга схем тенантов (Ruling 3) и bootstrap при старте (Ruling 8):
|
||||
`TenantProvisioningService` создаёт схему `tenant_<id>` и применяет tenant-миграции с историей
|
||||
`__TenantMigrationsHistory` в схеме тенанта; `TenantBootstrapService` (IHostedService) при старте создаёт
|
||||
дефолтного тенанта с фиксированным id и пользователя admin из env, затем провижинит схемы всех тенантов реестра.
|
||||
Временные заглушки Task 4 (`StartupSeed`, `PendingTenantProvisioner`) удалены; seed-код больше не трогает
|
||||
`DealDbContext` в Api. «30 дней» унифицировано: код-дефолт куки ссылается на `AuthService.SessionLifetimeDays`.
|
||||
|
||||
## Файлы
|
||||
|
||||
### Создан — `src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs`
|
||||
Реализация `ITenantProvisioner` (порт модуля Tenants), зависимость — `ConnectionStringProvider`.
|
||||
`ProvisionAsync(TenantId, ct)`:
|
||||
1. Открывает `NpgsqlConnection` на базовой строке (`ForTenant(null)`) и выполняет
|
||||
`TenantSchemaMigrator.CreateSchemaSql(tenantId.SchemaName)` — `CREATE SCHEMA IF NOT EXISTS`
|
||||
(DDL-идентификатор экранирует хелпер). Соединение закрывается через `await using`.
|
||||
2. Собирает опции `TenantDbContext` на строке `ForTenant(tenantId)` (уже с `Search Path=tenant_<id>`) с
|
||||
`npgsql.MigrationsHistoryTable("__TenantMigrationsHistory", tenantId.SchemaName)` и вызывает `Database.MigrateAsync`.
|
||||
3. Идемпотентность + защита от гонки параллельных провижинингов одного тенанта: статический
|
||||
`ConcurrentDictionary<string, SemaphoreSlim>` по имени схемы (`GetOrAdd` + `WaitAsync` + try/finally `Release`).
|
||||
Больше никакой синхронизации не добавлено.
|
||||
Комментарий: dev-роль `deal` имеет DDL-права — приемлемо для dev; в проде у прикладной роли DDL нет,
|
||||
миграции применяет служебная роль.
|
||||
|
||||
### Изменён — `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs`
|
||||
`AddDealPersistence` дополнительно регистрирует `services.AddScoped<ITenantProvisioner, TenantProvisioningService>()`.
|
||||
`ConnectionStringProvider` в Infrastructure не регистрируется: он singleton в `Program.cs` (проверено).
|
||||
|
||||
### Изменён — `src/core/Deal.Modules.Tenants/Application/IAuthStore.cs`
|
||||
Добавлен порт `Task CreateUserAsync(StoredUserDto user, CancellationToken ct)` (XML-doc: создаёт пользователя,
|
||||
Id задаёт вызывающий) — seed через порт модуля, а не через DbContext в Api.
|
||||
|
||||
### Изменён — `src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs`
|
||||
Реализация `CreateUserAsync`: маппинг DTO → `UserEntity` (Id/Login/TenantId/Status/PasswordHash из DTO,
|
||||
`CreatedAt = UtcNow`) + `SaveChangesAsync`.
|
||||
|
||||
### Изменён — `src/core/Deal.Modules.Tenants/Application/TenantService.cs`
|
||||
Добавлена перегрузка `CreateTenantAsync(string name, Guid id, CancellationToken ct)` (для bootstrap с фиксированным
|
||||
id, Ruling 8); прежняя `CreateTenantAsync(name, ct)` делегирует в неё с `Guid.NewGuid()`. Общий код
|
||||
сохранения (`TenantRepository.CreateAsync`) + провижининг (`ITenantProvisioner.ProvisionAsync`) вынесен в перегрузку с id.
|
||||
|
||||
### Создан — `src/core/Deal.Api/Hosting/TenantBootstrapService.cs` (IHostedService)
|
||||
`StartAsync` (scope через `IServiceScopeFactory`):
|
||||
1. `ITenantRepository.ListAsync` — если тенантов нет, создаёт дефолтного: фикс. id `00000000-0000-0000-0000-000000000001`,
|
||||
имя `Default`, через `TenantService.CreateTenantAsync(name, id, ct)` (сам вызывает провижининг).
|
||||
2. Если пользователя с логином (после нормализации lowercase/trim из env `DEAL_BOOTSTRAP_LOGIN`, дефолт `admin`) нет —
|
||||
создаёт `StoredUserDto` (Id=`Guid.NewGuid()`, Login нормализованный, TenantId=фикс. id дефолтного, Status=`active`,
|
||||
PasswordHash=`IPasswordHasher.Hash(password)`) через `IAuthStore.CreateUserAsync`. Пароль из env
|
||||
`DEAL_BOOTSTRAP_PASSWORD` (дефолт `admin`).
|
||||
3. Для ВСЕХ тенантов реестра (включая свежесозданного — повтор идемпотентен) — `ITenantProvisioner.ProvisionAsync`.
|
||||
`StopAsync` — пуст (ничего не держим). Использует только порты модулей + `IPasswordHasher`; `DealDbContext` в Api нет.
|
||||
`IConfiguration` читается на уровне Api (env-креды).
|
||||
|
||||
### Изменён — `src/core/Deal.Api/Program.cs`
|
||||
Убрана временная регистрация `ITenantProvisioner → PendingTenantProvisioner` и вызов
|
||||
`StartupSeed.EnsureSeedAsync` перед `Run`; добавлен `builder.Services.AddHostedService<TenantBootstrapService>()`.
|
||||
|
||||
### Удалены
|
||||
- `src/core/Deal.Api/Hosting/StartupSeed.cs` — seed переехал в `TenantBootstrapService` (провижининг схем, порты).
|
||||
- `src/core/Deal.Api/Hosting/PendingTenantProvisioner.cs` — DI-заглушка Task 4 (была помечена «удалить в Task 5»).
|
||||
|
||||
### Изменён — `src/core/Deal.Modules.Tenants/Application/AuthService.cs`
|
||||
`SessionLifetimeDays` стала `public const int SessionLifetimeDays = 30;` (XML-doc) — единый источник «30».
|
||||
|
||||
### Изменён — `src/core/Deal.Api/Configuration/CookieOptions.cs`
|
||||
`public int Days { get; set; } = AuthService.SessionLifetimeDays;` — код-дефолт ссылается на константу модуля;
|
||||
XML-doc переписан (конфигурация `Cookies__Days` при необходимости перекрывает дефолт).
|
||||
|
||||
### Изменён — `src/core/Deal.Api/Endpoints/AuthEndpoints.cs`
|
||||
Комментарий в `SetSessionCookie` приведён к новой реальности (MaxAge = `Cookies:Days`, код-дефолт — константа модуля).
|
||||
|
||||
### Изменены — `src/core/Deal.Api/appsettings.json`, `appsettings.Development.json`
|
||||
Из секции `Cookies` убран `"Days": 30` — «30» больше не дублируется в конфиге; значение по умолчанию течёт из
|
||||
`AuthService.SessionLifetimeDays` (см. CookieOptions).
|
||||
|
||||
### Изменён — `src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs`
|
||||
Добавлена реализация `CreateUserAsync` (добавляет пользователя в in-memory список и пишет `create-user:{id}` в `Calls`) —
|
||||
интерфейс `IAuthStore` расширен, тест-дублёр обязан его реализовать.
|
||||
|
||||
### Создан — `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh`
|
||||
Скрипт функциональной приёмки: два старта Deal.Api на :5080 с psql/curl-проверками и остановкой (см. ниже).
|
||||
|
||||
## Команды и вывод
|
||||
|
||||
### `dotnet build Deal.sln --nologo`
|
||||
```
|
||||
Сборка успешно выполнено через 2,0 с # 0 warnings / 0 errors (TreatWarningsAsErrors)
|
||||
```
|
||||
|
||||
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
|
||||
```
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 5,1 с
|
||||
```
|
||||
|
||||
### Функциональная приёмка (порт 5080)
|
||||
База очищена заранее: `DELETE FROM public.users; DELETE FROM public.tenants;` (порядок важен: sessions
|
||||
каскадно удаляются при удалении users; FK users→tenants — RESTRICT). В `public` до старта: 0 тенантов, 0 пользователей.
|
||||
|
||||
**RUN 1 — чистый старт** (`Deal.Api.exe`, `ASPNETCORE_ENVIRONMENT=Development`, ожидание 15 c):
|
||||
|
||||
a. Тенанты — 1 строка с фиксированным id:
|
||||
```
|
||||
Id | Name | Status
|
||||
--------------------------------------+---------+--------
|
||||
00000000-0000-0000-0000-000000000001 | Default | active
|
||||
```
|
||||
b. Пользователи:
|
||||
```
|
||||
Login
|
||||
-------
|
||||
admin
|
||||
```
|
||||
c. Схемы (`\dn`): `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`).
|
||||
d. Таблицы схемы тенанта (`\dt tenant_00000000000000000000000000000001.*`):
|
||||
`__TenantMigrationsHistory` и `settings`. История миграций содержит одну строку:
|
||||
```
|
||||
20260905193010_InitialTenant
|
||||
```
|
||||
e. Логин:
|
||||
```
|
||||
{"ok":true,"login":"admin"}
|
||||
HTTP 200
|
||||
```
|
||||
Ошибок/предупреждений в логе run1 нет (grep `error|exception|fail|warn|crit` — пусто). Bootstrap-последовательность в логе:
|
||||
`SELECT tenants (пусто)` → `INSERT tenants` → `SELECT users` → `INSERT users` → `SELECT tenants` → старт слушателя.
|
||||
|
||||
**RUN 2 — повторный старт (идемпотентность):**
|
||||
|
||||
- Тенанты: по-прежнему 1 строка (`00000000-...-0001`, Default) — дублей нет.
|
||||
- Пользователи: по-прежнему 1 (`admin`) — дублей нет.
|
||||
- Логин снова: `{"ok":true,"login":"admin"}` HTTP 200.
|
||||
- Лог run2: только `SELECT`-проверки (tenants → users → tenants для провижининга) — INSERT-ов seed нет,
|
||||
ошибок/предупреждений нет (идемпотентность подтверждена).
|
||||
- Оба процесса остановлены (kill + taskkill), порт :5080 свободен, процессов `Deal.Api` не осталось.
|
||||
|
||||
## Отклонения и решения
|
||||
|
||||
1. **Запуск не через `dotnet run`, а напрямую apphost `Deal.Api.exe`** (эквивалент `dotnet run --no-build`):
|
||||
`dotnet run` порождает дочерний процесс приложения, который переживает kill родителя и «залипает» на порту;
|
||||
прямой запуск собранного exe из Task 4 делал остановку детерминированной. Код тот же (сборка свежая, `--no-build` не требуется).
|
||||
2. **psql-запросы используют кавычки для PascalCase-колонок** (`"Id"`, `"Name"`, `"Status"`, `"Login"`): Task 2
|
||||
оставил колонки в именах C#-свойств (EF default, без snake_case). Буквальные команды из задания (`SELECT id, ...`)
|
||||
падают с `column "id" does not exist`. Функциональный смысл проверок a–e выполнен; переименование колонок —
|
||||
вне scope Task 5.
|
||||
3. **Из appsettings убран `Cookies:Days`** (см. задание «уберите дублирование „30“»): конфиг-секция больше не
|
||||
дублирует число «30»; дефолт теперь единственный — `AuthService.SessionLifetimeDays`. Поведение не изменилось
|
||||
(кука остаётся `max-age=2592000`, проверено приёмкой Task 4 ранее; здесь логин/Set-Cookie отработали на новом дефолте).
|
||||
Значение можно перекрыть env `Cookies__Days`.
|
||||
4. **В лог EF не пишутся DDL-команды провижининга** (CREATE SCHEMA выполняется сырым ADO-командами, DDL миграций —
|
||||
внутренним исполнителем EF без Command-событий 20101): факт применения подтверждён psql (таблицы и строка
|
||||
`InitialTenant` в истории схемы), ошибок в логах нет.
|
||||
5. **Двойной провижининг свежесозданного тенанта в первом старте**: `CreateTenantAsync` уже провижинит схему,
|
||||
затем цикл по всем тенантам вызывает `ProvisionAsync` повторно — по заданию допустимо («можно пропустить
|
||||
свежесозданного»), выбрана более простая ветка «провижинить всех»: повтор идемпотентен.
|
||||
|
||||
## Проверки (Acceptance Task 5)
|
||||
|
||||
1. `dotnet build Deal.sln` — 0 warnings / 0 errors. ✅
|
||||
2. `dotnet test tests/Deal.Tests.Unit` — 25 PASS. ✅
|
||||
3. Старт с чистой БД: tenants — строка с фикс. id `00000000-0000-0000-0000-000000000001`, users — `admin`. ✅
|
||||
4. Схема `tenant_00000000000000000000000000000001` создана; содержит `settings` и `__TenantMigrationsHistory`
|
||||
с `InitialTenant`. ✅
|
||||
5. Повторный старт идемпотентен: дублей нет (tenants/users по 1), ошибок в логах нет. ✅
|
||||
6. Login admin/admin работает после старта: `{"ok":true,"login":"admin"}`. ✅
|
||||
7. Стиль: 1 тип = 1 файл; XML-doc на public-контрактах; комментарии на русском; «30» — единый источник
|
||||
(public const модуля); заглушки Task 4 удалены. Сторонние модули/`backend/` не тронуты. ✅
|
||||
|
||||
Скрипт приёмки: `.superpowers/sdd/deal-stage1-tenancy/task-5-functional-check.sh` (оставлен, как task-4-скрипт).
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env sh
|
||||
# Task 6 финальная приёмка этапа 1 (см. план Task 6, п.2): полная curl-приёмка
|
||||
# (health, login, me, logout) + psql-проверка схем. Проект НЕ git.
|
||||
# Предполагается: postgres из deploy/compose.dev.yml поднят, БД deal с системными миграциями.
|
||||
|
||||
set -u
|
||||
|
||||
BASE_URL="http://localhost:5080"
|
||||
API_DIR="C:/telbase/src/core/Deal.Api"
|
||||
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
|
||||
JAR="/tmp/task6-jar.txt"
|
||||
LOG="/tmp/task6-api.log"
|
||||
PSQL="docker exec deal-postgres psql -U deal -d deal"
|
||||
|
||||
# Гарантия чистого порта: останавливаем возможные хвосты предыдущих прогонов.
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
rm -f "$JAR" "$LOG"
|
||||
|
||||
echo "== запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
|
||||
cd "$API_DIR" || exit 1
|
||||
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== остановка сервера (pid $PID) =="
|
||||
kill "$PID" 2>/dev/null
|
||||
sleep 2
|
||||
taskkill //F //IM Deal.Api.exe 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
sleep 12
|
||||
|
||||
echo
|
||||
echo "== 1. GET /api/health — ожидаем 200 {ok:true,service:deal} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" "$BASE_URL/api/health"
|
||||
|
||||
echo
|
||||
echo "== 2. POST /api/auth/login {admin,admin} — ожидаем 200 {ok:true,login:admin} + кука =="
|
||||
curl -s -c "$JAR" -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}'
|
||||
|
||||
echo
|
||||
echo "== 3. GET /api/auth/me с кукой — ожидаем 200 {login,ok} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -b "$JAR" "$BASE_URL/api/auth/me"
|
||||
|
||||
echo
|
||||
echo "== 4. POST /api/auth/logout — ожидаем 200 {ok:true} =="
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE_URL/api/auth/logout" -b "$JAR"
|
||||
|
||||
echo
|
||||
echo "== 5. psql: схемы (\dn) =="
|
||||
$PSQL -c '\dn'
|
||||
|
||||
echo
|
||||
echo "== 6. psql: таблицы public (\dt public.*) =="
|
||||
$PSQL -c '\dt public.*'
|
||||
|
||||
echo
|
||||
echo "== 7. psql: таблицы схемы тенанта =="
|
||||
$PSQL -c '\dt tenant_00000000000000000000000000000001.*'
|
||||
|
||||
echo
|
||||
echo "== 8. psql: применённые миграции =="
|
||||
$PSQL -c 'SELECT "MigrationId" FROM public."__EFMigrationsHistory";'
|
||||
$PSQL -c 'SELECT "MigrationId" FROM tenant_00000000000000000000000000000001."__TenantMigrationsHistory";'
|
||||
|
||||
echo
|
||||
echo "== хвост лога API (ошибок/предупреждений быть не должно) =="
|
||||
tail -n 15 "$LOG"
|
||||
|
||||
echo
|
||||
echo "== done =="
|
||||
@@ -0,0 +1,97 @@
|
||||
# Task 6 — Финал этапа: техдок и полная проверка. Отчёт
|
||||
|
||||
Дата: 2026-09-05. Проект НЕ git — фиксация отчётом. План: `docs/superpowers/plans/2026-09-05-deal-stage1-tenancy.md`
|
||||
(Task 6). Статус: **DONE (review pending)**. Сборка 0 warnings / 0 errors (`TreatWarningsAsErrors`), тесты 25 PASS,
|
||||
curl-приёмка health/login/me/logout на :5080 — все 200, psql-схемы и миграции на месте, `scripts/build.sh`
|
||||
и `scripts/test.sh` успешны.
|
||||
|
||||
## 1. Изменения в техдоке `docs/technical/Техническая-документация-Дейл.md`
|
||||
|
||||
Код/конфиги продукта не менялись. Только три точечные правки техдока:
|
||||
|
||||
1. **Новый раздел `## 13. Быстрый старт (dev, актуально для этапа 1)`** (в конец, после раздела 12) —
|
||||
фактический dev-путь этапа 1:
|
||||
- Postgres: `docker compose -f deploy/compose.dev.yml up -d` (контейнер `deal-postgres`, порт 5433, БД `deal`);
|
||||
- системные миграции из `src/core`: `dotnet ef database update --project Deal.Infrastructure
|
||||
--startup-project Deal.Api --context DealDbContext` (public: tenants/users/sessions, `InitialSystem`);
|
||||
- запуск: `dotnet run --project Deal.Api --urls http://localhost:5080` — при старте `TenantBootstrapService`
|
||||
(идемпотентно) создаёт дефолтного тенанта `00000000-0000-0000-0000-000000000001` (схема
|
||||
`tenant_00000000000000000000000000000001` с `settings`, миграция `InitialTenant`) и пользователя `admin`
|
||||
(env `DEAL_BOOTSTRAP_LOGIN/PASSWORD`, дефолт `admin`/`admin`);
|
||||
- проверка auth: `POST /api/auth/login` → `{"ok":true,"login":"admin"}`, кука `deal_session` (30 дней,
|
||||
источник — константа `AuthService.SessionLifetimeDays`, перекрытие `Cookies__Days`); прочие эндпоинты
|
||||
и `/api/health`;
|
||||
- psql-проверки схем: `\dn`, `\dt public.*`, `\dt tenant_*.*`;
|
||||
- сборка/тесты: `sh scripts/build.sh`, `sh scripts/test.sh`.
|
||||
2. **Раздел 11 «Известные ограничения и TODO»** — добавлен блок «Выполнено на этапе 1 (2026-09-05)»:
|
||||
доступ/сессии (login/logout/me/change-password, кука 30 дней) и мультитенантность/миграции
|
||||
(`InitialSystem`, схемы `tenant_<id>` с `InitialTenant`, провижининг + bootstrap). Остальные TODO
|
||||
не переписаны, помечены заголовком «Остаётся TODO».
|
||||
3. **Раздел 4 «Мультитенантность и БД»** — добавлен подраздел «Фактическая схема на конец этапа 1» с
|
||||
фактическими таблицами: `public.tenants/users/sessions` (колонки по конвенции EF Core, PascalCase) и
|
||||
`settings` в схеме тенанта, история миграций, автоматический провижининг/bootstrap. Forward-looking
|
||||
списки («Ключевые таблицы public/схемы тенанта (пример)») сохранены без изменений, явно помечены как
|
||||
целевой вид будущих этапов.
|
||||
|
||||
## 2. Полная проверка этапа
|
||||
|
||||
Все команды выполнены фактически (не по памяти):
|
||||
|
||||
### `dotnet build Deal.sln --nologo` (из `src/core`)
|
||||
```
|
||||
Сборка успешно выполнено через 1,9 с
|
||||
```
|
||||
0 warnings / 0 errors — гарантировано `TreatWarningsAsErrors=true` в `Directory.Build.props`.
|
||||
|
||||
### `dotnet test tests/Deal.Tests.Unit --no-build --nologo`
|
||||
```
|
||||
Сводка теста: всего: 25; сбой: 0; успешно: 25; пропущено: 0; длительность: 4,9 с
|
||||
```
|
||||
|
||||
### `dotnet ef migrations list` (оба контекста, `--no-connect`)
|
||||
- `DealDbContext`: `20260905192825_InitialSystem`; `TenantDbContext`: `20260905193010_InitialTenant`.
|
||||
Применение подтверждено psql (строки в `__EFMigrationsHistory` / `__TenantMigrationsHistory`).
|
||||
|
||||
### psql (`docker exec deal-postgres psql -U deal -d deal`)
|
||||
- `\dn`: схемы `public` и `tenant_00000000000000000000000000000001` (обе владелец `deal`).
|
||||
- `\dt public.*`: `tenants`, `users`, `sessions`, `__EFMigrationsHistory` (системная миграция применена).
|
||||
- `\dt tenant_00000000000000000000000000000001.*`: `settings`, `__TenantMigrationsHistory`
|
||||
(миграция `InitialTenant` применена на схему).
|
||||
- Данные: 1 тенант (`00000000-0000-0000-0000-000000000001`, Default, active), 1 пользователь (`admin`, active).
|
||||
|
||||
### Живой прогон API на :5080 (скрипт `.superpowers/sdd/deal-stage1-tenancy/task-6-functional-check.sh`)
|
||||
Запуск `Deal.Api.exe` (apphost, Development) — bootstrap отработал идемпотентно (дублей нет, ошибок в логе нет):
|
||||
- `GET /api/health` → `{"ok":true,"service":"deal"}` HTTP 200;
|
||||
- `POST /api/auth/login` {admin,admin} → `{"ok":true,"login":"admin"}` HTTP 200 + кука;
|
||||
- `GET /api/auth/me` с кукой → `{"login":"admin","ok":true}` HTTP 200;
|
||||
- `POST /api/auth/logout` → `{"ok":true}` HTTP 200.
|
||||
Процесс остановлен (kill + taskkill), проверено: процессов `Deal.Api` нет, порт освобождён.
|
||||
|
||||
### `sh scripts/build.sh && sh scripts/test.sh` (из корня репозитория)
|
||||
- build.sh: `Сборка успешно выполнено` (0/0);
|
||||
- test.sh: `Сводка теста: всего: 25; сбой: 0; успешно: 25`.
|
||||
|
||||
## 3. Отклонения и решения
|
||||
|
||||
1. **Запуск приёмки — через собранный apphost `Deal.Api.exe`, а не `dotnet run`** — как в Task 4/5:
|
||||
`dotnet run` оставляет переживающий kill дочерний процесс; прямой запуск делает остановку
|
||||
детерминированной. Код тот же (свежая сборка).
|
||||
2. **Создан артефакт-скрипт приёмки `task-6-functional-check.sh`** (по образцу task-4/task-5) — это не код
|
||||
и не конфиг продукта, а фиксация прогона в `.superpowers/sdd/`.
|
||||
3. **Раздел 11 не содержал буквальных пунктов «про login»/«про tenant-схемы»** (там были только общие TODO) —
|
||||
выполненные пункты добавлены отдельным блоком «Выполнено на этапе 1», остальной список не тронут.
|
||||
4. **Раздел 4 уже упоминал users в forward-looking списке**, поэтому фактическая схема этапа 1 (включая
|
||||
`sessions` и tenant `settings`, которых в целевом списке нет) зафиксирована отдельным подразделом
|
||||
«Фактическая схема на конец этапа 1» — forward-looking текст не удалялся.
|
||||
5. **В техдоке указаны фактические (PascalCase) имена колонок** — по конвенции EF Core, как в БД
|
||||
(см. task-5-report, отклонение 2).
|
||||
6. `dotnet ef migrations list` выполнялся с `--no-connect` (нет гарантии env для development-конфига);
|
||||
факт применения подтверждён psql-запросами к таблицам истории — расхождений нет.
|
||||
|
||||
## 4. Acceptance Task 6
|
||||
|
||||
1. `scripts/build.sh` / `scripts/test.sh` успешны; `migrations list` — System: InitialSystem, Tenant: InitialTenant. ✅
|
||||
2. Полная curl-приёмка: health, login, me, logout — все HTTP 200; psql-проверка схем на месте. ✅
|
||||
3. Техдок обновлён: раздел «Быстрый старт dev» (актуальные шаги), раздел 11 (выполнено на этапе 1),
|
||||
раздел 4 (фактическая схема). ✅
|
||||
4. `task-6-report.md` написан; финальная строка в `progress.md` (`## Task status`). ✅
|
||||
Reference in New Issue
Block a user