Подключить NSubstitute и мигрировать FakePasswordHasher, зафиксировать правила тестов
ci / build-test (push) Has been cancelled

NSubstitute 6.1.0 в 5 тест-проектов; эталон миграции: FakePasswordHasher удалён,
вместо него Support/TestHashers.New() (Substitute.For с детерминированной
семантикой fake-hash:, DI-регистрации переведены на инстанс хелпера).
Правила владельца в §11 код-стайла: классы напрямую не вызываем (DTO/хелперы/
экстеншены — исключения), моки через NSubstitute, маркерные классы не используем.
План миграции оставшихся фейков — backlog TD-TESTS-NSUBSTITUTE. Build 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 20:25:39 +03:00
parent 93e9100ae4
commit b745c324e3
30 changed files with 157 additions and 75 deletions
@@ -0,0 +1,29 @@
# Ledger: explicit-interfaces (2026-09-11, поздний вечер)
Решение владельца: вариант A — явные реализации по умолчанию, классы напрямую не вызываем
(исключения: DTO, хелперы, экстеншены), тесты через интерфейсы, моки — NSubstitute,
маркерные классы не используем (маркерные интерфейсы).
## Итог
- Codemod `scripts/make_explicit.py` (идемпотентный): таблицы членов интерфейсов (многострочные
сигнатуры), маппинг класс→интерфейсы включая partial-файлы, конвертация `public M(``IFoo.M(`.
Применено: 161 член в 30 прод-файлах.
- Исправления компиляторного цикла: недостающие using'и в partial-файлах (скрипт-фиксер), снят
дефолт параметра в явной реализации `ITenantLimitStore.GetOrCreateAsync`, самовызовы
`WTelegramSessionClient` квалифицированы `((ISessionClient)this)`, мусорный using в `LlmHttpClient`.
- Тесты: 17 файлов перетипизированы с конкретных классов на интерфейсы (поля, tuple-деконструкции,
var/target-typed new); DI-регистрации фейков → регистрация интерфейсных инстансов.
- Маркеры: 10 классов → интерфейсы `IKanbanModule`, `ICardsModule`, `IPipelineModule`,
`IDiscoveryModule`, `ISettingsModule`, `ITelegramModule`, `ITenantsModule`, `IContracts`,
`IInfrastructure`, `ISharedKernel`; тесты на `IsInterface`.
- NSubstitute 6.1.0 добавлен в 5 тест-проектов; `FakePasswordHasher` удалён, вместо него
`Support/TestHashers.New()` (Substitute.For + детерминированная семантика «fake-hash:»).
- Правила владельца зафиксированы в §11 код-стайла; план миграции оставшихся ~30 фейков —
`backlog.md` (TD-TESTS-NSUBSTITUTE).
## Проверка
- `dotnet build` 5 sln: 0 warnings / 0 errors.
- Тесты: core 1340/1340, telegram 130/130, ai 52/52, ml 38/38, storage 9/9.
- Коммиты: ed25c71 (явные реализации), 93e9100 (маркеры), далее — NSubstitute/доки.
+2 -1
View File
@@ -16,7 +16,8 @@
| BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress<ReclassifyProgressDto>`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE | | BL-RECLASS-SSE | **Сделано (2026-09-11):** пакетная переклассификация отдаёт промежуточный прогресс через SSE `cards_reclassified` (`{progress:true,done,total,moved,kept,trashed,skipped}`) и финальное событие (`{progress:false,reclassified,moved}`); `CardReclassifier.ReclassifyInboxAsync` принимает `IProgress<ReclassifyProgressDto>`; в UI — индикатор `done/total` в шапке «Неразобранного» | этап 12, D | P3 | DONE |
| TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto`. **Решение (2026-09-11): DEFERRED.** Наружный контракт единый; внутренние DTO (read/write/DB/patch) намеренно разделены по слоям, слияние — риск без пользы | этап 9/11 | P3 | DEFERRED | | TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto`. **Решение (2026-09-11): DEFERRED.** Наружный контракт единый; внутренние DTO (read/write/DB/patch) намеренно разделены по слоям, слияние — риск без пользы | этап 9/11 | P3 | DEFERRED |
| TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE | | TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки `<remarks>`, `<summary>` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE |
| TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов), повторный прогон — ещё 12; (3) дедупликация `<summary>``<inheritdoc/>`: **закрыто — дублей нет** (проверено сканами по тексту и по имени члена: 39 интерфейсов, 229 членов, случаев `<param>`+дубль не существует); (4) явная реализация интерфейсов — **остаётся точечным ревью владельца** (54 интерфейса с doc, 43 с реализациями, массовая правка не автоматизируется). Попутно: добавлены 4 недостающих `<summary>` членам интерфейсов, переведены 3 англоязычных комментария. Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`, `scripts/dedup_summary_inheritdoc.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (13 — DONE; 4 — ревью владельца) | | TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `<summary>` только блочно — 5286 шт.; (2) приватные XML-доки понижены — 2028+12; (3) дедупликация `<summary>``<inheritdoc/>` — дублей нет (сканы); (4) **явные реализации интерфейсов — сделано (2026-09-11, вечер, вариант A)**: 161 член в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py`, потребители перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов), Card/ICard-семейство оставлено implicit как DTO; попутно маркерные классы заменены маркерными интерфейсами. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | DONE |
| TD-TESTS-NSUBSTITUTE | Миграция тестовых фейков на NSubstitute (решение владельца 2026-09-11: моки — через NSubstitute, новых фейк-классов не заводить). **Сделано (2026-09-11):** NSubstitute 6.1.0 подключён к 5 тест-проектам; эталон миграции — `FakePasswordHasher` → хелпер `TestHashers.New()` (NSubstitute, детерминированная семантика сохранена), фейк удалён. **Осталось (по размеру):** FakeDiscoveryPacer (1 файл), FakeRatesListener (2), FakeTenantProvisioner (5), FakeSecretCipher (8), FakeAiTools (4), FakeRatesSource (1), FakeGlobalSettingsStore (4), FakeTenantRegistry/FakeTenantRepository (3+7), FakeAiClassifier (5), FakeSettingsStore (42), FakeRateLimitCounterStore (5), FakeAuditLogStore (13), FakeTenantStore (10), FakeMlLearningStore (5), FakeMlClient (17), FakeOperatorAuthStore (14), FakeInviteStore (7), FakeFileStorage (9), FakeTokenUsageEventStore (10), FakeAuthStore (15), Recording*/Harness* (gRPC-харнессы — оставить как хелперы), крупные stateful: FakeTelegramGateway (5), FakeDiscoveryGateway (2), FakeTelegramStore (7), FakeTenantLimitStore (15), FakePipelineStore (12), FakeDiscoveryStore (7), FakeKanjStore (21). Для каждого: заменить подставку на `Substitute.For<>()` + `Returns`, семантику состояния воспроизвести в конфигурации, тесты перетипизировать на интерфейс | решение владельца 2026-09-11 | P2 | BACKLOG |
| TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла. **Закрыто (2026-09-11):** (1) `var` — включён ломающий сборку гейт только для встроенных типов (`csharp_style_var_for_built_in_types = false:warning`), остаток выправлен `dotnet format style --diagnostics IDE0008` по 5 sln; режимы «очевидный/прочий тип» — silent осознанно (~1600 субъективных замен); (2) дедупликация `<summary>` — дублей нет (см. TD-COMMENTS-IFACE); (3) переводы строк — **решено: LF** (`.gitattributes` `* text=auto eol=lf`, `.editorconfig` → lf, 1029 файлов нормализовано, `git add --renormalize`; попутно починены 42 CRLF-.sh — до этого первый прогон удалённого CI падал бы). `this.` и именование приватных полей уже закрыты в `.editorconfig` | аудит 2026-09-11 | P3 | DONE | | TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла. **Закрыто (2026-09-11):** (1) `var` — включён ломающий сборку гейт только для встроенных типов (`csharp_style_var_for_built_in_types = false:warning`), остаток выправлен `dotnet format style --diagnostics IDE0008` по 5 sln; режимы «очевидный/прочий тип» — silent осознанно (~1600 субъективных замен); (2) дедупликация `<summary>` — дублей нет (см. TD-COMMENTS-IFACE); (3) переводы строк — **решено: LF** (`.gitattributes` `* text=auto eol=lf`, `.editorconfig` → lf, 1029 файлов нормализовано, `git add --renormalize`; попутно починены 42 CRLF-.sh — до этого первый прогон удалённого CI падал бы). `this.` и именование приватных полей уже закрыты в `.editorconfig` | аудит 2026-09-11 | P3 | DONE |
## 2. Инфраструктура и эксплуатация ## 2. Инфраструктура и эксплуатация
+9 -4
View File
@@ -232,11 +232,16 @@
- **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc, - **Не дублировать `<summary>` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc,
в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой). в классе-реализации достаточно `/// <inheritdoc/>` (или вообще ничего, если doc наследуется настройкой).
Текст описания пишется **один раз** — у интерфейса. Текст описания пишется **один раз** — у интерфейса.
- **Явная реализация интерфейсов — где возможно.** Предпочитать явную реализацию - **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11,
(`Task ICardStore.GetAsync(...)`), если член не является публичным API класса сам по себе. Если тип решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели
реализует член как собственный публичный сервис (нужен в DI/прямых вызовах) — допустима implicit, (напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные
но решение осознанное. реализации (codemod `scripts/make_explicit.py`, идемпотентный).
- Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа. - Один публичный тип интерфейса = один файл (как и для классов); имя файла = имя типа.
- **Маркерные классы не используются** — если нужен маркер, это маркерный интерфейс
(`IKanbanModule`, `ISharedKernel` и т.п.). **[изм. 2026-09-11]**
- **Тесты: моки — через NSubstitute** (`Substitute.For<IPasswordHasher>()`), тестовые переменные
типизируются интерфейсом. Новые hand-written фейк-классы не заводить; существующие мигрируются
поэтапно (план — `backlog.md`, `TD-TESTS-NSUBSTITUTE`). **[изм. 2026-09-11]**
## 12. Приложение: сводная таблица правил именования ## 12. Приложение: сводная таблица правил именования
@@ -34,9 +34,11 @@
(запрет только для встроенных типов — как в §4); режимы «очевидный тип» и «прочие» оставлены `silent` (запрет только для встроенных типов — как в §4); режимы «очевидный тип» и «прочие» оставлены `silent`
осознанно: правка субъективна и потребовала бы ~1600 механических замен. Остаток встроенных типов осознанно: правка субъективна и потребовала бы ~1600 механических замен. Остаток встроенных типов
выправлен `dotnet format style --diagnostics IDE0008` по всем 5 решениям (51 файл); сборка 5 sln — 0/0. выправлен `dotnet format style --diagnostics IDE0008` по всем 5 решениям (51 файл); сборка 5 sln — 0/0.
2. **Явная реализация интерфейсов (§11) — остаётся точечным ревью владельца.** Замер: 54 интерфейса с XML-doc, 2. **Явная реализация интерфейсов (§11) — выполнено (вечер, решение владельца, вариант A).** 161 член
из них 43 имеют реализации в src (в основном store-порты с одной реализацией). Массовая правка не в 30 прод-файлах конвертирован codemod'ом `scripts/make_explicit.py` (частичные классы и многострочные
автоматизируется сознательно (см. рекомендацию выше). сигнатуры учтены); потребители конкретных типов перетипизированы на интерфейсы (8 мест в проде,
17 тест-файлов); `Card`/семейство `I*Card` оставлены implicit — это DTO, их члены и есть публичный API.
Правило закреплено в §11 код-стайла: классы напрямую не вызываем (DTO/хелперы/экстеншены — исключения).
3. **Дедупликация `<summary>` через `<inheritdoc/>` — закрыто: дублей нет.** Проверено двумя независимыми 3. **Дедупликация `<summary>` через `<inheritdoc/>` — закрыто: дублей нет.** Проверено двумя независимыми
сканами (сопоставление по тексту и по имени члена интерфейса: 39 интерфейсов, 229 задокументированных сканами (сопоставление по тексту и по имени члена интерфейса: 39 интерфейсов, 229 задокументированных
членов) — реализаций, дублирующих summary интерфейсного члена, в продакшн-коде нет; случаев членов) — реализаций, дублирующих summary интерфейсного члена, в продакшн-коде нет; случаев
+10
View File
@@ -31,6 +31,16 @@
> остались точечным ревью владельца (43 интерфейса с реализациями, массовая правка не автоматизируется). > остались точечным ревью владельца (43 интерфейса с реализациями, массовая правка не автоматизируется).
> Сборка 5 sln 0/0; тесты: core **1340/1340**, telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9** — зелёные. > Сборка 5 sln 0/0; тесты: core **1340/1340**, telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9** — зелёные.
> Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md`, §2. > Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md`, §2.
>
> **2026-09-11 (поздний вечер) — явные реализации интерфейсов (вариант A, решение владельца).** Правило
> владельца: классы напрямую не вызываем (исключения — DTO, хелперы, экстеншены), тесты — через
> интерфейсы, моки — NSubstitute, маркерные классы не используем. Сделано: 161 член в 30 прод-файлах
> переведён на явные реализации codemod'ом `scripts/make_explicit.py`; потребители конкретных типов
> перетипизированы на интерфейсы (8 мест в проде, 17 тест-файлов; самовызовы — `((ISessionClient)this)`);
> 10 маркерных классов заменены маркерными интерфейсами (`IKanbanModule`…`ISharedKernel`); NSubstitute 6.1.0
> подключён к 5 тест-проектам, эталон миграции — `FakePasswordHasher` → `TestHashers.New()` (фейк удалён);
> правила зафиксированы в §11 код-стайла. Card/`I*Card` — implicit (DTO). Оставшиеся 30 фейков —
> поэтапная миграция (`backlog.md`, TD-TESTS-NSUBSTITUTE). Build 5 sln 0/0, тесты зелёные.
**Все этапы 0–12 выполнены (100%)** — см. roadmap **Все этапы 0–12 выполнены (100%)** — см. roadmap
> `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10 > `docs/superpowers/plans/2026-09-05-deal-roadmap.md`, план этапа 10
@@ -14,6 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="6.1.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api; namespace Deal.Tests.Unit.Api;
@@ -128,7 +129,7 @@ public sealed class OperatorBootstrapHostedServiceTests
.AddInMemoryCollection(values) .AddInMemoryCollection(values)
.Build(); .Build();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var logger = new ListLogger(); var logger = new ListLogger();
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService): // Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
// провайдер собирается с реальной регистрацией модуля поверх фейков. // провайдер собирается с реальной регистрацией модуля поверх фейков.
@@ -150,7 +151,7 @@ public sealed class OperatorBootstrapHostedServiceTests
private sealed record Context( private sealed record Context(
OperatorBootstrapHostedService Hosted, OperatorBootstrapHostedService Hosted,
FakeOperatorAuthStore Store, FakeOperatorAuthStore Store,
FakePasswordHasher PasswordHasher, IPasswordHasher PasswordHasher,
ListLogger Logs); ListLogger Logs);
// Окружение хоста с фиксированным именем (тестовый IHostEnvironment). // Окружение хоста с фиксированным именем (тестовый IHostEnvironment).
@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="6.1.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>
@@ -1,5 +1,6 @@
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -22,7 +23,7 @@ public sealed class AuthServiceTests
public AuthServiceTests() public AuthServiceTests()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
_store = new FakeAuthStore(); _store = new FakeAuthStore();
_tenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow)); _tenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); _store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
@@ -202,7 +203,7 @@ public sealed class AuthServiceTests
[Fact] [Fact]
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession() public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore(); var suspendedStore = new FakeAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
@@ -223,7 +224,7 @@ public sealed class AuthServiceTests
{ {
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает // Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты). // приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore(); var suspendedStore = new FakeAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
@@ -264,7 +265,7 @@ public sealed class AuthServiceTests
[Fact] [Fact]
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser() public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var multiUserStore = new FakeAuthStore(); var multiUserStore = new FakeAuthStore();
var firstUserId = Guid.NewGuid(); var firstUserId = Guid.NewGuid();
var secondUserId = Guid.NewGuid(); var secondUserId = Guid.NewGuid();
@@ -292,7 +293,7 @@ public sealed class AuthServiceTests
Assert.Empty(_store.Sessions); Assert.Empty(_store.Sessions);
// Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина). // Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина).
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var crossStore = new FakeAuthStore(); var crossStore = new FakeAuthStore();
crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p"))); crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p")));
var crossService = new AuthService(crossStore, passwordHasher, _tenantStore); var crossService = new AuthService(crossStore, passwordHasher, _tenantStore);
@@ -1,17 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// Детерминированный «хэшер» для тестов AuthService
/// </summary>
public sealed class FakePasswordHasher : IPasswordHasher
{
private const string Prefix = "fake-hash:";
/// <inheritdoc />
public string Hash(string password) => $"{Prefix}{password}";
/// <inheritdoc />
public bool Verify(string password, string encodedHash) => encodedHash == $"{Prefix}{password}";
}
@@ -1,5 +1,7 @@
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -28,7 +30,7 @@ public sealed class JoinFlowTests
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var service = NewService(inviteStore, tenantStore, provisioner, authStore, passwordHasher); var service = NewService(inviteStore, tenantStore, provisioner, authStore, passwordHasher);
// Email с регистром/пробелами нормализуется и совпадает с инвайтом; name задаёт имя нового тенанта. // Email с регистром/пробелами нормализуется и совпадает с инвайтом; name задаёт имя нового тенанта.
@@ -68,7 +70,7 @@ public sealed class JoinFlowTests
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), new FakeAuthStore(), new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), new FakeAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
@@ -86,7 +88,7 @@ public sealed class JoinFlowTests
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner, authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -107,7 +109,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт var tenantStore = new FakeTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -125,7 +127,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow)); var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -141,7 +143,7 @@ public sealed class JoinFlowTests
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New());
var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None);
@@ -158,7 +160,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -174,7 +176,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var service = NewService(inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakePasswordHasher()); var service = NewService(inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -187,7 +189,7 @@ public sealed class JoinFlowTests
{ {
var inviteStore = new FakeInviteStore(); var inviteStore = new FakeInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var service = NewService(inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakePasswordHasher()); var service = NewService(inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -202,7 +204,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None);
@@ -222,7 +224,7 @@ public sealed class JoinFlowTests
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash")); authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner, authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -247,7 +249,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, new FakeTenantProvisioner(), authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None);
@@ -266,7 +268,7 @@ public sealed class JoinFlowTests
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner, authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New());
JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
JoinResultDto second = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); JoinResultDto second = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -287,7 +289,7 @@ public sealed class JoinFlowTests
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner, authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -309,7 +311,7 @@ public sealed class JoinFlowTests
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var provisioner = new FakeTenantProvisioner(); var provisioner = new FakeTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner, authStore, new FakePasswordHasher()); var service = NewService(inviteStore, tenantStore, provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None); var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -355,7 +357,7 @@ public sealed class JoinFlowTests
FakeTenantStore tenantStore, FakeTenantStore tenantStore,
FakeTenantProvisioner provisioner, FakeTenantProvisioner provisioner,
FakeAuthStore authStore, FakeAuthStore authStore,
FakePasswordHasher passwordHasher) => IPasswordHasher passwordHasher) =>
new( new(
new InvitesService(inviteStore), new InvitesService(inviteStore),
new TenantService(tenantStore, provisioner), new TenantService(tenantStore, provisioner),
@@ -1,5 +1,6 @@
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -18,7 +19,7 @@ public sealed class OperatorAuthServiceTests
public OperatorAuthServiceTests() public OperatorAuthServiceTests()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore(); _store = new FakeOperatorAuthStore();
_store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); _store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
_service = new OperatorAuthService(_store, passwordHasher); _service = new OperatorAuthService(_store, passwordHasher);
@@ -117,7 +118,7 @@ public sealed class OperatorAuthServiceTests
public async Task ResolveSessionAsync_WhenOperatorIsNotActive_ReturnsNull() public async Task ResolveSessionAsync_WhenOperatorIsNotActive_ReturnsNull()
{ {
const string suspendedLogin = "suspended-operator"; const string suspendedLogin = "suspended-operator";
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
_store.AddOperator(new StoredOperatorDto(Guid.NewGuid(), suspendedLogin, "suspended", passwordHasher.Hash("x"))); _store.AddOperator(new StoredOperatorDto(Guid.NewGuid(), suspendedLogin, "suspended", passwordHasher.Hash("x")));
string rawToken = "suspended-operator-session-raw-token"; string rawToken = "suspended-operator-session-raw-token";
_store.AddSession(new OperatorSessionDto( _store.AddSession(new OperatorSessionDto(
@@ -1,5 +1,7 @@
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -9,12 +11,12 @@ namespace Deal.Tests.Unit.Modules.Tenants;
public sealed class OperatorBootstrapServiceTests public sealed class OperatorBootstrapServiceTests
{ {
private readonly FakeOperatorAuthStore _store; private readonly FakeOperatorAuthStore _store;
private readonly FakePasswordHasher _passwordHasher; private readonly IPasswordHasher _passwordHasher;
private readonly OperatorBootstrapService _service; private readonly OperatorBootstrapService _service;
public OperatorBootstrapServiceTests() public OperatorBootstrapServiceTests()
{ {
_passwordHasher = new FakePasswordHasher(); _passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore(); _store = new FakeOperatorAuthStore();
_service = new OperatorBootstrapService(_store, _passwordHasher); _service = new OperatorBootstrapService(_store, _passwordHasher);
} }
@@ -1,5 +1,7 @@
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -41,7 +43,7 @@ public sealed class TenantAdminServiceTests
{ {
var tenantStore = new FakeTenantStore(); var tenantStore = new FakeTenantStore();
var authStore = new FakeAuthStore(); var authStore = new FakeAuthStore();
var hasher = new FakePasswordHasher(); var hasher = TestHashers.New();
var service = NewService(tenantStore, authStore, new FakeTenantProvisioner(), hasher); var service = NewService(tenantStore, authStore, new FakeTenantProvisioner(), hasher);
TenantCreateResultDto result = await service.CreateAsync("Тенант с владельцем", " Owner@Example.COM ", CancellationToken.None); TenantCreateResultDto result = await service.CreateAsync("Тенант с владельцем", " Owner@Example.COM ", CancellationToken.None);
@@ -194,15 +196,15 @@ public sealed class TenantAdminServiceTests
Assert.Null(result.Tenant); Assert.Null(result.Tenant);
} }
// Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore/FakePasswordHasher). // Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку).
private static TenantAdminService NewService( private static TenantAdminService NewService(
FakeTenantStore tenantStore, FakeTenantStore tenantStore,
FakeAuthStore authStore, FakeAuthStore authStore,
FakeTenantProvisioner? provisioner = null, FakeTenantProvisioner? provisioner = null,
FakePasswordHasher? hasher = null) IPasswordHasher? hasher = null)
{ {
var tenantService = new TenantService(tenantStore, provisioner ?? new FakeTenantProvisioner()); var tenantService = new TenantService(tenantStore, provisioner ?? new FakeTenantProvisioner());
return new TenantAdminService(tenantStore, authStore, tenantService, hasher ?? new FakePasswordHasher()); return new TenantAdminService(tenantStore, authStore, tenantService, hasher ?? TestHashers.New());
} }
// Пользователь тенанта (активный, без хэша — для чтения). // Пользователь тенанта (активный, без хэша — для чтения).
@@ -10,6 +10,7 @@ using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -58,7 +59,7 @@ public sealed class JoinEndpointHttpTests
// Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. // Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated.
StoredUserDto user = Assert.Single(authStore.Users); StoredUserDto user = Assert.Single(authStore.Users);
Assert.Equal(Email, user.Login); Assert.Equal(Email, user.Login);
Assert.Equal(new FakePasswordHasher().Hash(Password), user.PasswordHash); Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash);
TenantRecordDto tenant = Assert.Single(tenantStore.Tenants); TenantRecordDto tenant = Assert.Single(tenantStore.Tenants);
Assert.Equal("Acme", tenant.Name); Assert.Equal("Acme", tenant.Name);
Assert.Equal(user.TenantId, tenant.Id); Assert.Equal(user.TenantId, tenant.Id);
@@ -260,9 +261,9 @@ public sealed class JoinEndpointHttpTests
builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port));
builder.Services.AddTenantsModule(); builder.Services.AddTenantsModule();
// FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает // Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
// последняя регистрация (зеркало OperatorAuthHttpHost). // последняя регистрация (зеркало OperatorAuthHttpHost).
builder.Services.AddSingleton<IPasswordHasher, FakePasswordHasher>(); builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<IAuthStore>(authStore); builder.Services.AddSingleton<IAuthStore>(authStore);
builder.Services.AddSingleton<IAuditLogStore>(auditStore); builder.Services.AddSingleton<IAuditLogStore>(auditStore);
builder.Services.AddSingleton<IInviteStore>(inviteStore); builder.Services.AddSingleton<IInviteStore>(inviteStore);
@@ -5,6 +5,7 @@ using Deal.Api.Configuration;
using Deal.Api.Services; using Deal.Api.Services;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -137,7 +138,7 @@ public sealed class LoginAttemptEndpointHttpTests
// Фейк-хранилище пользователей с активным пользователем Login/Password. // Фейк-хранилище пользователей с активным пользователем Login/Password.
private static FakeAuthStore NewUserStore() private static FakeAuthStore NewUserStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new FakeAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -4,6 +4,7 @@ using System.Text.Json;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -257,7 +258,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
@@ -266,7 +267,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
private static FakeAuthStore NewUserStore() private static FakeAuthStore NewUserStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new FakeAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword))); Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword)));
@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -280,7 +281,7 @@ public sealed class OperatorAuditEndpointsHttpTests
// Фейк-хранилище оператора с активным operator/operator. // Фейк-хранилище оператора с активным operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -293,7 +294,7 @@ public sealed class OperatorAuditEndpointsHttpTests
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore() private static FakeAuthStore NewUserStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new FakeAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -4,6 +4,7 @@ using System.Text.Json;
using Deal.Api.Extensions; using Deal.Api.Extensions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -190,7 +191,7 @@ public sealed class OperatorAuthEndpointsHttpTests
// Фейк-хранилище оператора с активным (или нет) оператором operator/operator. // Фейк-хранилище оператора с активным (или нет) оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore(bool active = true) private static FakeOperatorAuthStore NewOperatorStore(bool active = true)
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -203,7 +204,7 @@ public sealed class OperatorAuthEndpointsHttpTests
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore() private static FakeAuthStore NewUserStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new FakeAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Deal.Tests.Unit.Support;
using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions;
using TenantCookieOptions = Deal.Api.Configuration.CookieOptions; using TenantCookieOptions = Deal.Api.Configuration.CookieOptions;
@@ -181,9 +182,9 @@ internal static class OperatorAuthHttpHost
builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port));
builder.Services.AddTenantsModule(); builder.Services.AddTenantsModule();
// FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает // Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
// последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests). // последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests).
builder.Services.AddSingleton<IPasswordHasher, FakePasswordHasher>(); builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<ITenantContext, TenantContext>(); builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<IAuthStore>(userStore); builder.Services.AddSingleton<IAuthStore>(userStore);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore); builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -97,7 +98,7 @@ public sealed class OperatorHealthEndpointsHttpTests
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -4,6 +4,7 @@ using System.Text.Json;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -263,7 +264,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -327,7 +328,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -77,7 +78,7 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -5,6 +5,7 @@ using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -274,7 +275,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -4,6 +4,7 @@ using System.Text.Json;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -570,7 +571,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Фейк-хранилище оператора с активным оператором operator/operator. // Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore() private static FakeOperatorAuthStore NewOperatorStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore(); var store = new FakeOperatorAuthStore();
store.AddOperator(new StoredOperatorDto( store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -583,7 +584,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Фейк-хранилище пользователей с одним пользователем в целевом тенанте. // Фейк-хранилище пользователей с одним пользователем в целевом тенанте.
private static FakeAuthStore NewUserStore() private static FakeAuthStore NewUserStore()
{ {
var passwordHasher = new FakePasswordHasher(); var passwordHasher = TestHashers.New();
var store = new FakeAuthStore(); var store = new FakeAuthStore();
store.AddUser(new StoredUserDto( store.AddUser(new StoredUserDto(
Guid.NewGuid(), Guid.NewGuid(),
@@ -0,0 +1,26 @@
using NSubstitute;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// NSubstitute-хэшер пароля с детерминированной семантикой «fake-hash:пароль».
/// </summary>
public static class TestHashers
{
private const string Prefix = "fake-hash:";
/// <summary>
/// Создаёт хэшер-подставку: <see cref="IPasswordHasher.Hash"/> добавляет префикс,
/// <see cref="IPasswordHasher.Verify"/> сравнивает с ним.
/// </summary>
public static IPasswordHasher New()
{
IPasswordHasher hasher = Substitute.For<IPasswordHasher>();
hasher.Hash(Arg.Any<string>()).Returns(ci => Prefix + ci.ArgAt<string>(0));
hasher.Verify(Arg.Any<string>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<string>(1) == Prefix + ci.ArgAt<string>(0));
return hasher;
}
}
@@ -14,6 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="6.1.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>
@@ -7,6 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="6.1.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>
@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="6.1.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>