From c498afcc1c4136165dd24679d030f3e46760acf9 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:42:52 -0300 Subject: [PATCH 01/19] feat(localization): request localization, localized errors and user locale Framework slice of the i18n work (split of #1344 as requested in review). - `SharedResources` catalog (en + pt-BR) and `SupportedCultures` as the single source of supported tags. - `CustomException` carries `MessageKey`, `MessageArgs` and `ResourceSource`; `Message` stays English so logs remain culture-independent. `ILocalizableMessage` subclasses keep `UnauthorizedAccessException` / `KeyNotFoundException` as base types so audit severity classification is unaffected. - `GlobalExceptionHandler` localizes `title`/`detail` and surfaces the message key as a stable `code` extension on ProblemDetails. - `UseHeroLocalization` request-localization chain, UI-culture-only: `CurrentCulture` stays invariant, only `CurrentUICulture` is negotiated. `UserLocaleRequestCultureProvider` reads the `locale` claim, so the middleware sits between `UseAuthentication` and `UseAuthorization`. - `User.Locale` (`varchar(10)`, nullable, no database default; `en-US` is a code-level fallback) plus the `AddUserLocale` migration, the `locale` claim emission and the write-boundary validator rejecting tags outside `SupportedCultures.Tags`. - `LogContext.PushProperty` scoped in `using` blocks, fixing a pre-existing AsyncLocal leak that contaminated later log entries in the same request. - `SSH.NET` pin (`2026.0.0`), byte-identical to #1333, so `dotnet restore` passes while that PR is open. --- .agents/rules/architecture.md | 5 +- .agents/rules/localization.md | 77 ++ AGENTS.md | 1 + src/BuildingBlocks/Core/Core.csproj | 2 + .../Core/Exceptions/CustomException.cs | 20 +- .../Core/Exceptions/ForbiddenException.cs | 1 + .../Core/Exceptions/ILocalizableMessage.cs | 20 + .../LocalizedKeyNotFoundException.cs | 28 + .../LocalizedUnauthorizedAccessException.cs | 28 + .../Core/Exceptions/UnauthorizedException.cs | 1 + .../Core/Localization/SharedResources.cs | 4 + .../Localization/SharedResources.pt-BR.resx | 244 +++++ .../Core/Localization/SharedResources.resx | 244 +++++ .../Core/Localization/SupportedCultures.cs | 18 + src/BuildingBlocks/Jobs/Extensions.cs | 11 +- .../Storage/QuotaMeteredStorageService.cs | 6 +- .../Web/Exceptions/GlobalExceptionHandler.cs | 123 ++- src/BuildingBlocks/Web/Extensions.cs | 6 + .../Localization/LocalizationExtensions.cs | 75 ++ .../UserLocaleRequestCultureProvider.cs | 31 + src/Directory.Packages.props | 7 + .../20260720062947_AddUserLocale.Designer.cs | 846 ++++++++++++++++++ .../Identity/20260720062947_AddUserLocale.cs | 31 + .../IdentityDbContextModelSnapshot.cs | 4 + .../DTOs/UserDto.cs | 3 + .../Services/IUserProfileService.cs | 2 +- .../Services/IUserService.cs | 2 +- .../v1/Users/UpdateUser/UpdateUserCommand.cs | 1 + .../Data/IdentityConfigurations.cs | 7 + .../Modules.Identity/Domain/FshUser.cs | 3 + .../StartImpersonationCommandHandler.cs | 34 +- .../UpdateUser/UpdateUserCommandHandler.cs | 1 + .../UpdateUser/UpdateUserCommandValidator.cs | 17 +- .../Features/v1/Users/UserImageValidator.cs | 10 +- .../Localization/IdentityResources.cs | 4 + .../Localization/IdentityResources.pt-BR.resx | 265 ++++++ .../Localization/IdentityResources.resx | 265 ++++++ .../Modules.Identity/Modules.Identity.csproj | 2 +- .../Services/IdentityService.cs | 81 +- .../Services/UserProfileService.cs | 46 +- .../Modules.Identity/Services/UserService.cs | 4 +- .../ExceptionSeverityClassifierTests.cs | 40 + .../SharedResourcesKeyParityTests.cs | 40 + .../SharedResourcesLocalizationTests.cs | 51 ++ .../SharedResourcesLocalizerFactory.cs | 27 + .../UserLocaleRequestCultureProviderTests.cs | 138 +++ ...GlobalExceptionHandlerLocalizationTests.cs | 241 +++++ .../Web/GlobalExceptionHandlerTests.cs | 6 +- .../Data/IdentityDbContextModelTests.cs | 56 ++ .../StartImpersonationCommandHandlerTests.cs | 100 +++ .../Handlers/UpdateUserCommandHandlerTests.cs | 8 +- .../Services/CreateBasicClaimsTests.cs | 38 + .../Services/UserLocaleTests.cs | 95 ++ .../SharedResourcesLocalizerFactory.cs | 18 + .../Support/TestAsyncQueryable.cs | 68 ++ .../UpdateUserCommandValidatorTests.cs | 100 ++- 56 files changed, 3520 insertions(+), 86 deletions(-) create mode 100644 .agents/rules/localization.md create mode 100644 src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs create mode 100644 src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs create mode 100644 src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.cs create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx create mode 100644 src/BuildingBlocks/Core/Localization/SharedResources.resx create mode 100644 src/BuildingBlocks/Core/Localization/SupportedCultures.cs create mode 100644 src/BuildingBlocks/Web/Localization/LocalizationExtensions.cs create mode 100644 src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs create mode 100644 src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs create mode 100644 src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx create mode 100644 src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs create mode 100644 src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs create mode 100644 src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs create mode 100644 src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs create mode 100644 src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs create mode 100644 src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs create mode 100644 src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs create mode 100644 src/Tests/Identity.Tests/Services/UserLocaleTests.cs create mode 100644 src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs create mode 100644 src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md index cf3fa6b6ef..3f63770aac 100644 --- a/.agents/rules/architecture.md +++ b/.agents/rules/architecture.md @@ -81,8 +81,9 @@ In `src/BuildingBlocks/Web/Extensions.cs` (`UseHeroPlatform`): 2. **CORS before HTTPS redirect** (so OPTIONS preflight isn't 307-redirected) 3. HttpsRedirection → SecurityHeaders → static files → Routing 4. **`UseAuthentication`** -5. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth -6. RateLimiting → Quotas → `UseAuthorization` → `MapModules` +5. **`UseHeroLocalization`** — request localization, sits **between `UseAuthentication` and `UseAuthorization`** so the user-`locale`-claim culture provider can read `HttpContext.User` +6. **`UseModuleMiddlewares`** — each module's `ConfigureMiddleware`, runs **after** auth +7. RateLimiting → Quotas → `UseAuthorization` → `MapModules` `app.UseHeroMultiTenantDatabases()` (Finbuckle `UseMultiTenant()`) runs in `Program.cs` **before** `UseHeroPlatform`, i.e. **before `UseAuthentication`** — so tenant resolution is header-driven, not claim-driven. See `modules/multitenancy.md`. diff --git a/.agents/rules/localization.md b/.agents/rules/localization.md new file mode 100644 index 0000000000..c723615991 --- /dev/null +++ b/.agents/rules/localization.md @@ -0,0 +1,77 @@ +# Localization (i18n) + +`src/BuildingBlocks/Core/Localization/` + per-module `Localization/` folders. Read before adding any user-facing message (exception, validation, API error). **The client's culture decides which words the client reads; it never decides how the API formats numbers or dates.** + +## Culture negotiation (already wired — don't re-add) + +`AddHeroLocalization()` / `UseHeroLocalization()` (`BuildingBlocks/Web/Localization/`) negotiate the request **UI** culture in this order: `?culture=` query → `locale` JWT claim (`UserLocaleRequestCultureProvider`) → `Accept-Language` → configured default → `en-US`. Supported tags live in `SupportedCultures.Tags`. The culture is set before endpoints and the exception handler run, so any `IStringLocalizer` resolved downstream picks up the request culture automatically. + +**`CultureInfo.CurrentUICulture` only — `CurrentCulture` stays invariant.** An API that emits JSON must not shift `ToString()`, `Parse()` or interpolation per request; both React apps format at the presentation layer. `RequestLocalizationMiddleware` assigns both cultures unconditionally, so the culture half is pinned rather than left alone: `DefaultRequestCulture` carries `(InvariantCulture, configured default)` and `SupportedCultures` is `null` so the middleware skips culture filtering. Do **not** "fix" this by adding `AddSupportedCultures(...)`; `Formatting_culture_stays_invariant_while_ui_culture_negotiates` fails if you do. + +`SupportedCultures.Tags` is **specific tags only**, no neutrals. A request asking for a bare `pt`, or for an unsupported variant like `pt-PT`, resolves to the configured default rather than being served a language it was not translated into. The React apps canonicalize variants onto supported tags (`CANON` in `clients/*/src/i18n.ts`) before calling the API, so app traffic is unaffected; a hand-rolled client sending bare `pt` gets the default. Adding a language means: add its specific tag to `Tags`, add a `*.{tag}.resx` per catalog, add its JSON catalogs to both apps, and remove it from `CANON` if it was being folded into another tag. + +## Catalogs — hybrid, one marker per catalog + +- **Core (`SharedResources`)** — generic / cross-cutting messages: ProblemDetails titles (`Error.*`), cross-module errors (`Error.TenantContextRequired`, `Error.NoCurrentUser`, …), and shared validation (`Validation.*`). +- **Per module (`Resources`)** — domain-specific messages owned by the module: `src/Modules//Modules./Localization/Resources.cs` (marker `public sealed class Resources;`) + co-located `Resources.resx` (neutral / en-US) + `Resources.pt-BR.resx`. `ResourcesPath = ""` (co-located), so the resx manifest name must equal the marker's full type name. + +Catalogs are named for **specific** cultures (`.pt-BR`, never a neutral `.pt`), matching the front-end catalog folders. The neutral, un-suffixed `.resx` is the en-US / ultimate-fallback catalog. + +Key naming: `Error..` for domain messages (`Catalog.ProductNotFound`), `Error.` / `Validation.` for Core. PascalCase. Placeholders are `{0}`, `{1}` (`string.Format` via the localizer) — **not** the frontend's `{{name}}`. + +**Placeholder arguments must be culture-insensitive.** The localizer's indexer calls `string.Format` under `CurrentCulture`, which is invariant (above). Pass `int`/`long`/`string`/enum — never a `double`, `decimal`, `DateTime` or `TimeSpan.TotalX`, which would render with an invariant separator instead of the reader's. Where a count is conceptually whole, expose it as an `int` at the source rather than converting at the call site (see `GetAuditsQueryHandler.MaxWindowDays` next to `MaxWindow`). Money and dates belong in structured response fields formatted by the client, not interpolated into a message. + +## Exceptions — localize at the boundary, log stays English + +Throw with the **English message** as `Exception.Message` (used for logs and fallback) plus the resource key metadata. **Never** pre-localize the message at the throw site. + +```csharp +// domain message -> module catalog +throw new NotFoundException($"Product {id} not found.") +{ + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [id], + ResourceSource = typeof(CatalogResources), +}; + +// cross-cutting message -> Core catalog (ResourceSource omitted = SharedResources) +throw new UnauthorizedException("Tenant context is required.") +{ + MessageKey = "Error.TenantContextRequired", +}; +``` + +`GlobalExceptionHandler` resolves `Title` (by status) and `Detail` (via `MessageKey` + `ResourceSource`) under the request culture, and falls back to `Exception.Message` when the key is missing (`ResourceNotFound`) or malformed (`FormatException`). Migration is therefore incremental: an un-migrated `throw new NotFoundException("...")` still renders its English literal. + +**Do NOT** set `ProblemDetails` from a localized string in logs — the handler logs `Exception.Message` (English) and the type name, never the translated body. + +## Validators — inject the localizer, defer resolution + +```csharp +public sealed class XCommandValidator : AbstractValidator +{ + public XCommandValidator(IStringLocalizer localizer) + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage(_ => localizer["Validation.NameRequired"]); + } +} +``` + +Always the `.WithMessage(_ => localizer["Key"])` lambda (resolution is deferred to `Validate()`, under the request culture) — never `.WithMessage(localizer["Key"])`. **Catalog choice:** inject `IStringLocalizer` for genuinely shared/generic validation (`Validation.*` already in Core, reuse them), or `IStringLocalizer<Resources>` for module-specific validation messages kept in the module's own catalog. DI provides the localizer automatically (`AddValidatorsFromAssembly` + `AddHeroLocalization` + the module's own `AddLocalization`); nested validators (`Include(new PagedQueryValidator(localizer))`) receive it from the parent. + +## Known behaviour (documented, not bugs) + +- **The `locale` claim lags a language switch by one token.** The culture provider reads the JWT `locale` claim, so a switch does not reach the API until the next token issue. The front-end persists the choice to the profile and re-mints, so it converges; in the window between, the shell can be in the new language while an API error still comes back in the old one. Deliberate: the alternative is a per-request DB read on every authenticated call. +- **Impersonation carries the operator's language, not the target's.** `StartImpersonationCommandHandler` strips the target's `locale` claim so the operator keeps reading in their own language, and the cross-app handoff URL carries `locale` because the dashboard is normally on a different origin and cannot read admin's `i18nextLng`. During impersonation the switcher is client-side only — it must not PUT onto the impersonated user's profile. +- **SignalR does not carry the app locale.** The hub client builds its own requests instead of going through `apiFetch`, so `Accept-Language` on the negotiate is the browser's. Applies to every session. `handoff-locale.spec.ts` names the exception explicitly so any *other* channel that stops carrying the locale fails the test. + +## Tests (required with every catalog change) + +- **Parity** — every key present in both the neutral and the `pt-BR` catalog, for Core and every `Resources`. Per-catalog tests live in each module's test project; `CatalogParityTests` in `Architecture.Tests` enumerates every module catalog generically, so a **new** module catalog is covered without adding a test. +- **Code → resx guard** — every referenced key (`MessageKey`, `localizer["…"]`) must exist in its catalog, or the build fails. This is what catches a forgotten/typo `ResourceSource` (which would otherwise fall back silently). +- Build validators/handlers with a real localizer from the embedded catalog via `SharedResourcesLocalizerFactory.Create()` (test-project `Support/` helper), not a stub. + +## Emails / background handlers + +Integration-event handlers run without an HTTP request, so there is no negotiated culture. Localizing outbound emails needs the recipient's stored locale propagated to the handler — **not yet implemented** (tracked for a future PR); email bodies stay English for now. diff --git a/AGENTS.md b/AGENTS.md index cbe60e9e1f..6603c5120e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ Single long-lived branch: **`main`** (the default) — there is **no `develop`** | CORS, security headers, rate limiting, idempotency, quotas | `security.md` | | SignalR / SSE backend | `realtime.md` | | Logging, correlation, OpenTelemetry | `logging.md` | +| Localization (i18n), request culture, resource catalogs, localized exceptions | `localization.md` | | Unit test conventions, NetArchTest | `testing.md` | | Integration tests (Testcontainers harness + gotchas) | `integration-testing.md` | | **Modifying `src/BuildingBlocks`** (read first — it's protected) | `buildingblocks-protection.md` | diff --git a/src/BuildingBlocks/Core/Core.csproj b/src/BuildingBlocks/Core/Core.csproj index 3c2e01bfdb..0af6dc03eb 100644 --- a/src/BuildingBlocks/Core/Core.csproj +++ b/src/BuildingBlocks/Core/Core.csproj @@ -3,6 +3,8 @@ FSH.Framework.Core FSH.Framework.Core + + $(NoWarn);S2094 diff --git a/src/BuildingBlocks/Core/Exceptions/CustomException.cs b/src/BuildingBlocks/Core/Exceptions/CustomException.cs index 02dfa51699..ddc183c5d7 100644 --- a/src/BuildingBlocks/Core/Exceptions/CustomException.cs +++ b/src/BuildingBlocks/Core/Exceptions/CustomException.cs @@ -7,7 +7,7 @@ namespace FSH.Framework.Core.Exceptions; /// FullStackHero exception used for consistent error handling across the stack. /// Includes HTTP status codes and optional detailed error messages. /// -public class CustomException : Exception +public class CustomException : Exception, ILocalizableMessage { /// /// A list of error messages (e.g., validation errors, business rules). @@ -19,6 +19,24 @@ public class CustomException : Exception /// public HttpStatusCode StatusCode { get; } + /// + /// Optional resource key resolved against to localize the + /// response body under the request culture. When null, the literal + /// is used. The message itself always stays the (English) fallback for logs. + /// + public string? MessageKey { get; init; } + + /// + /// Format arguments applied to the localized message ({0}, {1}, …). + /// + public IReadOnlyList MessageArgs { get; init; } = []; + + /// + /// Marker type identifying the resource catalog for . When null, + /// the shared (Core) catalog is used; module-specific keys point it at the module's own catalog. + /// + public Type? ResourceSource { get; init; } + /// /// Initializes a new instance of the class with default message and internal server error status. /// diff --git a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs index 5033c897b4..2a26e7f0a4 100644 --- a/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs +++ b/src/BuildingBlocks/Core/Exceptions/ForbiddenException.cs @@ -12,6 +12,7 @@ public class ForbiddenException : CustomException public ForbiddenException() : base("Unauthorized access.", Array.Empty(), HttpStatusCode.Forbidden) { + MessageKey = "Error.ForbiddenAccess"; } /// diff --git a/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs b/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs new file mode 100644 index 0000000000..1aa1d7a698 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/ILocalizableMessage.cs @@ -0,0 +1,20 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// Implemented by exceptions whose response Detail can be localized from a resource key. +/// Lets GlobalExceptionHandler translate the body under the request culture while the +/// exception type stays intact — needed for BCL types the audit severity classifier keys off +/// (e.g. , ). +/// The stays the English fallback for logs and unresolved keys. +/// +public interface ILocalizableMessage +{ + /// Resource key resolved against ; null keeps the literal message. + string? MessageKey { get; } + + /// Format arguments applied to the localized message ({0}, {1}, …). + IReadOnlyList MessageArgs { get; } + + /// Marker type identifying the resource catalog; null uses the shared (Core) catalog. + Type? ResourceSource { get; } +} diff --git a/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs b/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs new file mode 100644 index 0000000000..0bd3d44009 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/LocalizedKeyNotFoundException.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// whose 404 response Detail is localized via . +/// Subclasses the BCL type on purpose so audit exception-type fixtures and severity classification that +/// key off keep working, while the body still translates under the +/// request culture. The base message stays the English log fallback. +/// +public sealed class LocalizedKeyNotFoundException : KeyNotFoundException, ILocalizableMessage +{ + public string? MessageKey { get; init; } + public IReadOnlyList MessageArgs { get; init; } = []; + public Type? ResourceSource { get; init; } + + public LocalizedKeyNotFoundException() + { + } + + public LocalizedKeyNotFoundException(string message) + : base(message) + { + } + + public LocalizedKeyNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs b/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs new file mode 100644 index 0000000000..c17db1dd97 --- /dev/null +++ b/src/BuildingBlocks/Core/Exceptions/LocalizedUnauthorizedAccessException.cs @@ -0,0 +1,28 @@ +namespace FSH.Framework.Core.Exceptions; + +/// +/// whose 401 response Detail is localized via +/// . Subclasses the BCL type on purpose so the audit severity classifier +/// (which maps to Warning) keeps working, while the body +/// still translates under the request culture. The base message stays the English log fallback. +/// +public sealed class LocalizedUnauthorizedAccessException : UnauthorizedAccessException, ILocalizableMessage +{ + public string? MessageKey { get; init; } + public IReadOnlyList MessageArgs { get; init; } = []; + public Type? ResourceSource { get; init; } + + public LocalizedUnauthorizedAccessException() + { + } + + public LocalizedUnauthorizedAccessException(string message) + : base(message) + { + } + + public LocalizedUnauthorizedAccessException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs index b3815a4b7b..3acf01fd1d 100644 --- a/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs +++ b/src/BuildingBlocks/Core/Exceptions/UnauthorizedException.cs @@ -12,6 +12,7 @@ public class UnauthorizedException : CustomException public UnauthorizedException() : base("Authentication failed.", Array.Empty(), HttpStatusCode.Unauthorized) { + MessageKey = "Error.AuthenticationFailed"; } /// diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.cs b/src/BuildingBlocks/Core/Localization/SharedResources.cs new file mode 100644 index 0000000000..8dd0ce5452 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Framework.Core.Localization; + +/// Marker type binding IStringLocalizer<SharedResources> to the shared resx catalog. +public sealed class SharedResources; diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx new file mode 100644 index 0000000000..f9d387c648 --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ocorreram um ou mais erros de validação. + + + Ocorreram um ou mais erros de validação. + + + Não autorizado + + + Não encontrado + + + Requisição inválida + + + Conflito + + + Acesso negado + + + Nenhum usuário autenticado. + + + Tenant inválido ou ausente. + + + O contexto do tenant é obrigatório. + + + Ocorreu um erro inesperado + + + Ocorreu um erro inesperado. Tente novamente mais tarde. + + + Limite de aplicação + + + Contas de superadministrador devem usar o aplicativo de administração. Entre por lá, não pelo painel da organização. + + + Falha na autenticação. + + + Acesso não autorizado. + + + Cota de armazenamento excedida ({0}/{1} bytes). + + + O valor de Take deve estar entre 1 e {0}. + + + A duração deve estar entre 1 e {0} minutos. + + + Somente estas extensões são permitidas: {0} + + + O arquivo deve ter no máximo {0} MB. + + + Opções de banco de dados não encontradas. + + + O provedor de armazenamento {0} do Hangfire não é suportado. + + + O ID do usuário é obrigatório. + + + Você não pode enviar uma nova imagem e excluir a atual ao mesmo tempo. + + + Idioma não suportado. + + + O ID do grupo é obrigatório. + + + É necessário pelo menos um ID de usuário. + + + Os IDs de usuário não podem ser vazios ou conter apenas espaços. + + + O nome do grupo é obrigatório. + + + O nome do grupo não pode exceder 256 caracteres. + + + A descrição não pode exceder 1024 caracteres. + + + O ID da função é obrigatório. + + + O nome da função é obrigatório. + + + O número da página deve ser maior ou igual a 1. + + + O tamanho da página deve ser maior ou igual a 1. + + + O tamanho da página deve estar entre 1 e 100. + + + A expressão de ordenação não pode exceder 200 caracteres. + + + O ID da sessão é obrigatório. + + + O motivo não pode exceder 500 caracteres. + + + A lista de funções do usuário é obrigatória. + + + O código de confirmação é obrigatório. + + + A organização é obrigatória. + + + A senha atual é obrigatória. + + + A nova senha é obrigatória. + + + A nova senha deve ser diferente da senha atual. + + + Esta senha foi usada recentemente. Escolha uma senha diferente. + + + As senhas não coincidem. + + + O nome é obrigatório. + + + O nome não pode exceder 100 caracteres. + + + O sobrenome é obrigatório. + + + O sobrenome não pode exceder 100 caracteres. + + + O e-mail é obrigatório. + + + É necessário um endereço de e-mail válido. + + + O nome de usuário é obrigatório. + + + O nome de usuário deve ter pelo menos 3 caracteres. + + + O nome de usuário não pode exceder 50 caracteres. + + + A senha é obrigatória. + + + A senha deve ter pelo menos 6 caracteres. + + + A confirmação de senha é obrigatória. + + + O número de telefone não pode exceder 20 caracteres. + + diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.resx b/src/BuildingBlocks/Core/Localization/SharedResources.resx new file mode 100644 index 0000000000..742a6022ed --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + One or more validation errors occurred. + + + One or more validation errors occurred. + + + Unauthorized + + + Not Found + + + Bad Request + + + Conflict + + + Forbidden + + + No authenticated user. + + + Invalid or missing tenant. + + + Tenant context is required. + + + An unexpected error occurred + + + An unexpected error occurred. Please try again later. + + + App boundary + + + SuperAdmin accounts must use the admin app. Sign in there instead of the tenant dashboard. + + + Authentication failed. + + + Unauthorized access. + + + Storage quota exceeded ({0}/{1} bytes). + + + Take must be between 1 and {0}. + + + Duration must be between 1 and {0} minutes. + + + Only these extensions are allowed: {0} + + + File must be <= {0} MB. + + + Database options not found. + + + Hangfire storage provider {0} is not supported. + + + User ID is required. + + + You cannot upload a new image and delete the current one simultaneously. + + + Unsupported locale. + + + Group ID is required. + + + At least one user ID is required. + + + User IDs cannot be empty or whitespace. + + + Group name is required. + + + Group name must not exceed 256 characters. + + + Description must not exceed 1024 characters. + + + Role ID is required. + + + Role name is required. + + + Page number must be greater than or equal to 1. + + + Page size must be greater than or equal to 1. + + + Page size must be between 1 and 100. + + + Sort expression must not exceed 200 characters. + + + Session ID is required. + + + Reason must not exceed 500 characters. + + + User roles list is required. + + + Confirmation code is required. + + + Tenant is required. + + + Current password is required. + + + New password is required. + + + New password must be different from the current password. + + + This password has been used recently. Please choose a different password. + + + Passwords do not match. + + + First name is required. + + + First name must not exceed 100 characters. + + + Last name is required. + + + Last name must not exceed 100 characters. + + + Email is required. + + + A valid email address is required. + + + Username is required. + + + Username must be at least 3 characters. + + + Username must not exceed 50 characters. + + + Password is required. + + + Password must be at least 6 characters. + + + Password confirmation is required. + + + Phone number must not exceed 20 characters. + + diff --git a/src/BuildingBlocks/Core/Localization/SupportedCultures.cs b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs new file mode 100644 index 0000000000..6b00f20e3a --- /dev/null +++ b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs @@ -0,0 +1,18 @@ +namespace FSH.Framework.Core.Localization; + +/// Canonical set of cultures the platform supports for user-facing localization. +public static class SupportedCultures +{ + /// Guaranteed ultimate fallback culture, served by the neutral (un-suffixed) catalog. + public const string Default = "en-US"; + + /// + /// Specific tags a user may persist, the switcher offers, and Accept-Language is matched against. + /// Deliberately specific-only, with no neutral entries: every catalog is named for a specific + /// culture (*.pt-BR.resx), matching the front-end catalogs. A request asking for a bare + /// pt, or for an unsupported variant like pt-PT, therefore resolves to + /// rather than being silently served Brazilian strings. Adding a language + /// means adding its specific tag here plus a *.{tag}.resx per catalog. + /// + public static readonly string[] Tags = ["en-US", "pt-BR"]; +} diff --git a/src/BuildingBlocks/Jobs/Extensions.cs b/src/BuildingBlocks/Jobs/Extensions.cs index 9ab773b0fc..57a9b4da58 100644 --- a/src/BuildingBlocks/Jobs/Extensions.cs +++ b/src/BuildingBlocks/Jobs/Extensions.cs @@ -32,7 +32,10 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) { var configuration = provider.GetRequiredService(); var dbOptions = configuration.GetSection(nameof(DatabaseOptions)).Get() - ?? throw new CustomException("Database options not found"); + ?? throw new CustomException("Database options not found") + { + MessageKey = "Jobs.DatabaseOptionsNotFound", + }; switch (dbOptions.Provider.ToUpperInvariant()) { @@ -48,7 +51,11 @@ public static IServiceCollection AddHeroJobs(this IServiceCollection services) break; default: - throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported"); + throw new CustomException($"Hangfire storage provider {dbOptions.Provider} is not supported") + { + MessageKey = "Jobs.UnsupportedStorageProvider", + MessageArgs = [dbOptions.Provider], + }; } config.UseActivator(new FshJobActivator(provider.GetRequiredService())); diff --git a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs index 2dcef1323f..3938bf8aef 100644 --- a/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs +++ b/src/BuildingBlocks/Storage/QuotaMeteredStorageService.cs @@ -69,7 +69,11 @@ public async Task UploadAsync(FileUploadRequest request, FileType fil throw new CustomException( $"Storage quota exceeded ({check.CurrentUsage}/{check.Limit} bytes).", errors: null, - HttpStatusCode.InsufficientStorage); + HttpStatusCode.InsufficientStorage) + { + MessageKey = "Storage.QuotaExceeded", + MessageArgs = [check.CurrentUsage, check.Limit], + }; } try diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index 8e8987dae7..903f6704ed 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -1,16 +1,78 @@ using System.Diagnostics; using System; +using System.Net; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Core.Localization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Serilog.Context; namespace FSH.Framework.Web.Exceptions; -public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +public class GlobalExceptionHandler( + ILogger logger, + IStringLocalizer localizer, + IStringLocalizerFactory localizerFactory) : IExceptionHandler { + // Returns null for a status with no title of its own. Deliberately NOT a catch-all + // "Error.Unexpected": that key exists, so ResourceNotFound would be false and a 409 would + // report "An unexpected error occurred" alongside Status 409 and a Detail describing a + // perfectly ordinary business-rule conflict — a title contradicting its own status code. + // A null key keeps the pre-localization behaviour (the exception type name) for every + // status not translated here, which is at least status-consistent. + private static string? TitleKeyFor(HttpStatusCode statusCode) => statusCode switch + { + HttpStatusCode.NotFound => "Error.NotFound", + HttpStatusCode.Unauthorized => "Error.Unauthorized", + HttpStatusCode.Forbidden => "Error.Forbidden", + HttpStatusCode.BadRequest => "Error.BadRequest", + HttpStatusCode.Conflict => "Error.Conflict", + _ => null, + }; + + // Resolves the localized Detail for an exception carrying a MessageKey, under the request culture. + // The [key, args] indexer runs string.Format; a stray '{' in the resx would throw FormatException + // from inside the handler, so fall back to the (English) message on a format error or a missing key. + // A null key keeps the literal message. Shared by the CustomException, Unauthorized and NotFound + // branches so a localized BCL subclass (ILocalizableMessage) translates the same way. + private string LocalizeDetail(ILocalizableMessage localizable, string fallbackMessage) + { + if (localizable.MessageKey is null) + { + return fallbackMessage; + } + + var moduleLocalizer = localizerFactory.Create(localizable.ResourceSource ?? typeof(SharedResources)); + try + { + var message = localizable.MessageArgs.Count == 0 + ? moduleLocalizer[localizable.MessageKey] + : moduleLocalizer[localizable.MessageKey, localizable.MessageArgs.ToArray()]; + return message.ResourceNotFound ? fallbackMessage : message.Value; + } + catch (FormatException) + { + return fallbackMessage; + } + } + + // Writes the localized Detail and, when the exception carries a MessageKey, surfaces that key as a + // stable machine-readable "code". Detail is prose under the request culture, so a client that needs + // to branch on a specific error (a terminal state, a dedicated screen, a retry) keys off the code + // instead of matching text that changes with Accept-Language. + private void ApplyLocalizedDetail(ProblemDetails problemDetails, ILocalizableMessage localizable, string fallbackMessage) + { + problemDetails.Detail = LocalizeDetail(localizable, fallbackMessage); + + if (localizable.MessageKey is not null) + { + problemDetails.Extensions["code"] = localizable.MessageKey; + } + } + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(httpContext); @@ -28,8 +90,8 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e statusCode = StatusCodes.Status400BadRequest; problemDetails.Status = statusCode; - problemDetails.Title = "Validation error"; - problemDetails.Detail = "One or more validation errors occurred."; + problemDetails.Title = localizer["Error.Validation"]; + problemDetails.Detail = localizer["Error.Validation.Detail"]; problemDetails.Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"; var errors = fluentException.Errors @@ -43,10 +105,13 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e else if (exception is CustomException e) { statusCode = (int)e.StatusCode; - problemDetails.Status = statusCode; - problemDetails.Title = e.GetType().Name; - problemDetails.Detail = e.Message; + + var titleKey = TitleKeyFor(e.StatusCode); + var title = titleKey is null ? null : localizer[titleKey]; + problemDetails.Title = title is null || title.ResourceNotFound ? e.GetType().Name : title.Value; + + ApplyLocalizedDetail(problemDetails, e, e.Message); if (e.ErrorMessages is { Count: > 0 }) { @@ -57,15 +122,29 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e { statusCode = StatusCodes.Status401Unauthorized; problemDetails.Status = statusCode; - problemDetails.Title = "Unauthorized"; - problemDetails.Detail = exception.Message; + problemDetails.Title = localizer["Error.Unauthorized"]; + if (exception is ILocalizableMessage unauthorizedLoc) + { + ApplyLocalizedDetail(problemDetails, unauthorizedLoc, exception.Message); + } + else + { + problemDetails.Detail = exception.Message; + } } else if (exception is KeyNotFoundException) { statusCode = StatusCodes.Status404NotFound; problemDetails.Status = statusCode; - problemDetails.Title = "Not Found"; - problemDetails.Detail = exception.Message; + problemDetails.Title = localizer["Error.NotFound"]; + if (exception is ILocalizableMessage notFoundLoc) + { + ApplyLocalizedDetail(problemDetails, notFoundLoc, exception.Message); + } + else + { + problemDetails.Detail = exception.Message; + } } else if (exception is BadHttpRequestException badRequest) { @@ -73,15 +152,15 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e // Client error carrying the correct status (usually 400) — honour it instead of falling through to a generic 500. statusCode = badRequest.StatusCode; problemDetails.Status = statusCode; - problemDetails.Title = "Bad Request"; + problemDetails.Title = localizer["Error.BadRequest"]; problemDetails.Detail = badRequest.Message; } else { statusCode = StatusCodes.Status500InternalServerError; problemDetails.Status = statusCode; - problemDetails.Title = "An unexpected error occurred"; - problemDetails.Detail = "An unexpected error occurred. Please try again later."; + problemDetails.Title = localizer["Error.Unexpected"]; + problemDetails.Detail = localizer["Error.Unexpected.Detail"]; } httpContext.Response.StatusCode = statusCode; @@ -94,12 +173,18 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e ?? httpContext.TraceIdentifier; problemDetails.Extensions["correlationId"] = correlationId; - LogContext.PushProperty("exception_title", problemDetails.Title); - LogContext.PushProperty("exception_detail", problemDetails.Detail); - LogContext.PushProperty("exception_statusCode", problemDetails.Status); - LogContext.PushProperty("exception_stackTrace", exception.StackTrace); - - logger.LogError("Exception at {Path} - {StatusCode} {Title}", httpContext.Request.Path.Value?.Replace(Environment.NewLine, string.Empty), statusCode, problemDetails.Title); + // Log the raw (English) exception message and type, never the localized ProblemDetails body, + // so log entries stay culture-independent regardless of the request's negotiated culture. + // PushProperty returns an IDisposable that pops the property on dispose; scope it to the LogError + // call so it does not leak onto every subsequent log entry of the request (AsyncLocal contamination). + var logPath = httpContext.Request.Path.Value?.Replace(Environment.NewLine, string.Empty); + using (LogContext.PushProperty("exception_type", exception.GetType().Name)) + using (LogContext.PushProperty("exception_detail", exception.Message)) + using (LogContext.PushProperty("exception_statusCode", statusCode)) + using (LogContext.PushProperty("exception_stackTrace", exception.StackTrace)) + { + logger.LogError("Exception at {Path} - {StatusCode} {Type}", logPath, statusCode, exception.GetType().Name); + } await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken).ConfigureAwait(false); return true; diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 50c6568fda..5b21d659dd 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -9,6 +9,7 @@ using FSH.Framework.Web.Exceptions; using FSH.Framework.Web.FeatureFlags; using FSH.Framework.Web.Idempotency; +using FSH.Framework.Web.Localization; using FSH.Framework.Web.Sse; using FSH.Framework.Web.Health; using FSH.Framework.Web.Mediator.Behaviors; @@ -129,6 +130,7 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild builder.Services.AddHeroQuotas(builder.Configuration); } + builder.Services.AddHeroLocalization(builder.Configuration); builder.Services.AddExceptionHandler(); builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); builder.Services.AddProblemDetails(); @@ -185,6 +187,10 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action + /// Registers request localization: resx-backed IStringLocalizer and a UI-culture-provider + /// chain of Query → user locale claim → Accept-Language → configured default → en-US. + /// The per-deployment default is read from LocalizationOptions:DefaultCulture and validated + /// against the whitelist, so garbage config falls back to the guaranteed default culture. + /// Negotiation drives only — + /// stays invariant, so no request can shift numeric, + /// date or string formatting anywhere in the pipeline. + /// + public static IServiceCollection AddHeroLocalization(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var configured = configuration["LocalizationOptions:DefaultCulture"]; + var defaultCulture = SupportedCultures.Tags.Contains(configured!) ? configured! : SupportedCultures.Default; + + // ResourcesPath = "" because SharedResources and its resx live in the same folder/namespace + // (co-located). A non-empty path would double the prefix and IStringLocalizer would silently + // fall back to the raw key. The resx-resolution test guards this value. + services.AddLocalization(o => o.ResourcesPath = ""); + + services.Configure(o => + { + // UI-culture-only. RequestLocalizationMiddleware.SetCurrentThreadCulture assigns BOTH + // CurrentCulture and CurrentUICulture unconditionally, so there is no "leave formatting + // alone" switch: the culture half has to be pinned instead. Two things make that work. + // 1. DefaultRequestCulture carries the pair, and the middleware resolves the culture half + // as `cultureInfo ??= DefaultRequestCulture.Culture`. Pinning that half to invariant + // makes invariant the only value CurrentCulture can ever take. + // 2. SupportedCultures = null makes the middleware skip culture filtering entirely. + // A one-element [InvariantCulture] list would behave the same but log + // `UnsupportedCultures` on EVERY request: the middleware's parent-culture walk bails + // out at the empty culture name, so invariant is unmatchable by design. + // An API that emits JSON has no business shifting ToString()/Parse() per request; both + // React apps already format at the presentation layer. See #1344 review. + o.DefaultRequestCulture = new RequestCulture(CultureInfo.InvariantCulture, new CultureInfo(defaultCulture)); + o.SupportedCultures = null; + o.AddSupportedUICultures(SupportedCultures.Tags); + o.ApplyCurrentCultureToResponseHeaders = true; + + // Default order is [Query(0), Cookie(1), AcceptLanguage(2)]. Drop the cookie provider by + // type (order-independent, so a framework reshuffle of the defaults can't silently remove + // the wrong provider) and insert the user-claim provider right after query, so the final + // chain is Query → UserLocaleClaim → AcceptLanguage → configured default → en-US neutral resx. + var cookieProvider = o.RequestCultureProviders + .FirstOrDefault(p => p is CookieRequestCultureProvider); + if (cookieProvider is not null) + { + o.RequestCultureProviders.Remove(cookieProvider); + } + + o.RequestCultureProviders.Insert(1, new UserLocaleRequestCultureProvider()); + }); + + return services; + } + + public static IApplicationBuilder UseHeroLocalization(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + return app.UseRequestLocalization(); + } +} diff --git a/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs b/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs new file mode 100644 index 0000000000..8c0e3a7e45 --- /dev/null +++ b/src/BuildingBlocks/Web/Localization/UserLocaleRequestCultureProvider.cs @@ -0,0 +1,31 @@ +using System.Security.Claims; +using FSH.Framework.Core.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; + +namespace FSH.Framework.Web.Localization; + +/// +/// Resolves the request culture from the authenticated user's locale claim +/// (mirrors the persisted User.Locale). Sits after the query-string provider and before +/// the Accept-Language provider: a supported claim wins over the browser header, an unsupported +/// or absent claim falls through to the next provider. +/// +public sealed class UserLocaleRequestCultureProvider : RequestCultureProvider +{ + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var claim = httpContext.User.FindFirstValue("locale"); + if (IsSupported(claim)) + { + return Task.FromResult(new ProviderCultureResult(claim!)); + } + + return NullProviderCultureResult; + } + + private static bool IsSupported(string? tag) => + !string.IsNullOrWhiteSpace(tag) && SupportedCultures.Tags.Contains(tag); +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..7674befa8f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -143,5 +143,12 @@ AccessViolation). Transitive pinning is enabled, so this entry alone bumps it. Remove once the SignalR backplane package depends on a patched version itself. --> + + \ No newline at end of file diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs new file mode 100644 index 0000000000..38997b0597 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs @@ -0,0 +1,846 @@ +// +using System; +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260720062947_AddUserLocale")] + partial class AddUserLocale + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Framework.Eventing.Inbox.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id", "HandlerName"); + + b.ToTable("InboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Framework.Eventing.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDead") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.ToTable("OutboxMessages", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName", "TenantId") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ObjectId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiryTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName", "TenantId") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreatedOnUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsSystemGroup") + .HasColumnType("boolean"); + + b.Property("LastModifiedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("ModifiedBy"); + + b.Property("LastModifiedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ModifiedAt"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("Name"); + + b.ToTable("Groups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("GroupId", "RoleId"); + + b.HasIndex("GroupId"); + + b.HasIndex("RoleId"); + + b.ToTable("GroupRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.ImpersonationGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClientId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedTenantId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImpersonatedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokeReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedByUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.HasIndex("ActorUserId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ActorUserId_StartedAtUtc"); + + b.HasIndex("ImpersonatedTenantId", "StartedAtUtc") + .HasDatabaseName("IX_ImpersonationGrants_ImpersonatedTenantId_StartedAtUtc"); + + b.ToTable("ImpersonationGrants", "identity"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("PasswordHistory", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("AddedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroups", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Browser") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BrowserVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("LastActivityAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OperatingSystem") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OsVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("RevokedReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RefreshTokenHash"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "identity"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRoleClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.GroupRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("GroupRoles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.PasswordHistory", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserGroup", b => + { + b.HasOne("FSH.Modules.Identity.Domain.Group", "Group") + .WithMany("UserGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.UserSession", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FSH.Modules.Identity.Domain.FshUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.FshUser", b => + { + b.Navigation("PasswordHistories"); + }); + + modelBuilder.Entity("FSH.Modules.Identity.Domain.Group", b => + { + b.Navigation("GroupRoles"); + + b.Navigation("UserGroups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs new file mode 100644 index 0000000000..566fb18c49 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Identity +{ + /// + public partial class AddUserLocale : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Locale", + schema: "identity", + table: "Users", + type: "character varying(10)", + maxLength: 10, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Locale", + schema: "identity", + table: "Users"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs index 34efbeb275..89d3e50615 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/IdentityDbContextModelSnapshot.cs @@ -128,6 +128,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastPasswordChangeDate") .HasColumnType("timestamp with time zone"); + b.Property("Locale") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + b.Property("LockoutEnabled") .HasColumnType("boolean"); diff --git a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs index 0ccd71384e..343cb17dbb 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/DTOs/UserDto.cs @@ -22,4 +22,7 @@ public class UserDto /// Whether the user has enrolled in TOTP-based two-factor authentication. public bool TwoFactorEnabled { get; set; } + + /// BCP 47 UI language tag (e.g. "pt-BR"); null resolves to the default culture. + public string? Locale { get; set; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs index f305b4a782..5cc4bbed96 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs @@ -26,7 +26,7 @@ public interface IUserProfileService /// /// Updates a user's profile. /// - Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default); + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default); /// /// Sets the profile image URL directly (no upload). Used by the presigned-upload flow: diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs index 91ab3467fa..edc323581a 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserService.cs @@ -15,7 +15,7 @@ public interface IUserService Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken); Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal, CancellationToken cancellationToken = default); Task RegisterAsync(string firstName, string lastName, string email, string userName, string password, string confirmPassword, string phoneNumber, string origin, CancellationToken cancellationToken); - Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default); + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default); Task DeleteAsync(string userId, CancellationToken cancellationToken = default); Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); Task AdminConfirmEmailAsync(string userId, CancellationToken cancellationToken = default); diff --git a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs index 09292a46bc..90c7740862 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs +++ b/src/Modules/Identity/Modules.Identity.Contracts/v1/Users/UpdateUser/UpdateUserCommand.cs @@ -12,4 +12,5 @@ public class UpdateUserCommand : ICommand public string? Email { get; set; } public FileUploadRequest? Image { get; set; } public bool DeleteCurrentImage { get; set; } + public string? Locale { get; set; } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs index e13d7842f2..7dc1105f5e 100644 --- a/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs +++ b/src/Modules/Identity/Modules.Identity/Data/IdentityConfigurations.cs @@ -19,6 +19,13 @@ public void Configure(EntityTypeBuilder builder) builder .Property(u => u.ObjectId) .HasMaxLength(256); + + // A BCP-47 tag is short and bounded; 10 covers language-script-region (zh-Hant-TW). Writes are + // additionally constrained to SupportedCultures.Tags by UpdateUserCommandValidator, so this is + // the storage-level backstop, not the validation. + builder + .Property(u => u.Locale) + .HasMaxLength(10); } } diff --git a/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs b/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs index 270d87fd96..536abe934c 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/FshUser.cs @@ -15,6 +15,9 @@ public class FshUser : IdentityUser, IHasDomainEvents public string? RefreshToken { get; set; } public DateTime RefreshTokenExpiryTime { get; set; } + /// BCP 47 UI language tag (e.g. "pt-BR"); null resolves to the default culture. + public string? Locale { get; set; } + public string? ObjectId { get; set; } /// Timestamp when the user last changed their password diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs index c5a4288fc0..4876de715f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs @@ -6,6 +6,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -58,7 +59,10 @@ public async ValueTask Handle( var actorUserId = _currentUser.GetUserId().ToString(); var actorTenantId = _currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var actorUserName = _currentUser.Name; // Cross-tenant impersonation requires the actor to be in the root tenant. Tenant admins @@ -66,7 +70,11 @@ public async ValueTask Handle( if (!string.Equals(actorTenantId, MultitenancyConstants.Root.Id, StringComparison.Ordinal) && !string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { - throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators"); + throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators") + { + MessageKey = "Identity.CrossTenantImpersonationRestricted", + ResourceSource = typeof(IdentityResources), + }; } // Prevent self-impersonation (pointless, confuses the audit trail). Caller error → explicit 4xx, @@ -74,7 +82,11 @@ public async ValueTask Handle( if (string.Equals(actorUserId, request.TargetUserId, StringComparison.Ordinal) && string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal)) { - throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest); + throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.CannotImpersonateYourself", + ResourceSource = typeof(IdentityResources), + }; } // Prevent nesting: if the caller is already impersonating, require end-impersonation first. @@ -85,7 +97,11 @@ public async ValueTask Handle( throw new CustomException( "end current impersonation before starting a new one", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.EndImpersonationFirst", + ResourceSource = typeof(IdentityResources), + }; } var targetClaimsResult = await _identityService @@ -93,7 +109,11 @@ public async ValueTask Handle( if (targetClaimsResult is null) { - throw new NotFoundException("target user not found"); + throw new NotFoundException("target user not found") + { + MessageKey = "Identity.TargetUserNotFound", + ResourceSource = typeof(IdentityResources), + }; } var (subject, claims) = targetClaimsResult.Value; @@ -104,7 +124,9 @@ public async ValueTask Handle( // ImpersonationGrant row and the issued JWT share the same jti. var jti = Guid.NewGuid().ToString("N"); var impersonationClaims = claims - .Where(c => c.Type != JwtRegisteredClaimNames.Jti) + // Drop the target's locale: language is a presentation concern, so the operator reads in + // THEIR own language (falls through to Accept-Language), not the impersonated user's. + .Where(c => c.Type != JwtRegisteredClaimNames.Jti && c.Type != "locale") .Concat( [ new Claim(JwtRegisteredClaimNames.Jti, jti), diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs index 9b6608e03a..64772a10a5 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -24,6 +24,7 @@ await _userService.UpdateAsync( command.PhoneNumber ?? string.Empty, command.Image!, command.DeleteCurrentImage, + command.Locale, cancellationToken).ConfigureAwait(false); return Unit.Value; diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs index 6fc722d9d9..b8b4ef32ac 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -1,16 +1,18 @@ -using FluentValidation; +using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Storage; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; public sealed class UpdateUserCommandValidator : AbstractValidator { - public UpdateUserCommandValidator() + public UpdateUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) .NotEmpty() - .WithMessage("User ID is required."); + .WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.FirstName) .MaximumLength(50) @@ -31,12 +33,17 @@ public UpdateUserCommandValidator() When(x => x.Image is not null, () => { RuleFor(x => x.Image!) - .SetValidator(new UserImageValidator(FileType.Image)); + .SetValidator(new UserImageValidator(FileType.Image, localizer)); }); // Prevent deleting and uploading image at the same time RuleFor(x => x) .Must(x => !(x.DeleteCurrentImage && x.Image is not null)) - .WithMessage("You cannot upload a new image and delete the current one simultaneously."); + .WithMessage(_ => localizer["Validation.ImageUploadDeleteConflict"]); + + RuleFor(x => x.Locale) + .Must(l => SupportedCultures.Tags.Contains(l!)) + .When(x => !string.IsNullOrWhiteSpace(x.Locale)) + .WithMessage(_ => localizer["Validation.UnsupportedLocale"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs index 283bfae87b..faf930994e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UserImageValidator.cs @@ -1,24 +1,26 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Storage; using FSH.Framework.Storage; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users; public sealed class UserImageValidator : AbstractValidator { - public UserImageValidator() : this(FileType.Image) { } - public UserImageValidator(FileType fileType) + public UserImageValidator(IStringLocalizer localizer) : this(FileType.Image, localizer) { } + public UserImageValidator(FileType fileType, IStringLocalizer localizer) { var rules = FileTypeMetadata.GetRules(fileType); RuleFor(x => x.FileName) .NotEmpty() .Must(file => rules.AllowedExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) - .WithMessage($"Only these extensions are allowed: {string.Join(", ", rules.AllowedExtensions)}"); + .WithMessage(_ => localizer["Validation.AllowedExtensions", string.Join(", ", rules.AllowedExtensions)]); RuleFor(x => x.Data) .NotEmpty() .Must(data => data.Count <= rules.MaxSizeInMB * 1024 * 1024) - .WithMessage($"File must be <= {rules.MaxSizeInMB} MB."); + .WithMessage(_ => localizer["Validation.MaxFileSize", rules.MaxSizeInMB]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs new file mode 100644 index 0000000000..880bb0749f --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Identity.Localization; + +/// Marker type binding IStringLocalizer<IdentityResources> to the Identity resx catalog. +public sealed class IdentityResources; diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx new file mode 100644 index 0000000000..c9e3c5d814 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.pt-BR.resx @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Grupo com ID '{0}' não encontrado. + + + Usuários não encontrados: {0} + + + Já existe um grupo com o nome '{0}'. + + + Funções não encontradas: {0} + + + Grupos do sistema não podem ser excluídos. + + + O usuário '{0}' não é membro do grupo '{1}'. + + + Usuários não podem ser removidos de um grupo padrão. + + + Grupos do sistema não podem ser modificados. + + + Função não encontrada. + + + Funções não encontradas. + + + Funções do sistema não podem ser modificadas. + + + Não é possível renomear uma função para o nome de uma função do sistema. + + + Não é possível criar uma função usando o nome de uma função do sistema. + + + Funções do sistema não podem ser excluídas. + + + As permissões de funções do sistema são gerenciadas pelo framework e não podem ser modificadas. + + + A operação falhou. + + + A sessão atual não é uma sessão de personificação. + + + Ator original não encontrado. + + + Concessão de personificação não encontrada. + + + A personificação entre tenants é restrita a operadores da plataforma. + + + Não é possível personificar a si mesmo. + + + Encerre a personificação atual antes de iniciar uma nova. + + + Usuário alvo não encontrado. + + + Usuário não encontrado. + + + Usuário {0} não encontrado. + + + A senha atual está incorreta. + + + Falha ao gerar a chave do autenticador. + + + O código do autenticador é inválido. + + + Token de atualização inválido. + + + A sessão foi revogada. + + + O assunto do token de acesso não corresponde. + + + two_factor_required: É necessário um código de autenticador para concluir o login. + + + two_factor_invalid: O código do autenticador é inválido ou expirou. + + + A conta está temporariamente bloqueada devido a muitas tentativas de login malsucedidas. Tente novamente mais tarde. + + + O token de atualização é inválido ou expirou. + + + O usuário está desativado. + + + E-mail não confirmado. + + + O tenant {0} está desativado. + + + A validade do tenant {0} expirou. + + + Erro ao redefinir a senha. + + + Falha ao alterar a senha. + + + Falha ao atualizar o perfil. + + + Falha ao atualizar a imagem do perfil. + + + Ocorreu um erro ao confirmar o e-mail. + + + Ocorreu um erro ao confirmar {0}. + + + Ocorreu um erro ao confirmar o e-mail de {0}: {1} + + + O e-mail de {0} já está confirmado. + + + Ocorreu um erro ao confirmar o número de telefone. + + + Ocorreu um erro ao confirmar o número de telefone {0}. + + + A claim de e-mail é obrigatória para autenticação externa. + + + Falha ao criar o usuário a partir do principal externo. + + + As senhas não coincidem. + + + Não foi possível registrar o usuário. + + + Administradores não podem remover a própria função de administrador. + + + O administrador do tenant raiz não pode ser rebaixado. + + + O tenant deve manter pelo menos um administrador. + + + Apenas administradores podem alterar o status do usuário. + + + Usuários não podem desativar a si mesmos. + + + Administradores não podem ser desativados. + + + O tenant deve ter pelo menos um administrador ativo. + + + Falha ao alternar o status. + + + Credenciais inválidas. + + + Não é possível visualizar sessões de outro usuário + + + Não é possível revogar a sessão de outro usuário + + + Não é possível revogar sessões de outro usuário + + + RoleManager<FshRole> não resolvido. Verifique o registro do Identity. + + + Repositório de funções não configurado. Garanta .AddRoles<FshRole>() e os stores do EF. + + + Método reservado para inicialização em escopo. + + diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx new file mode 100644 index 0000000000..5cc85fa437 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResources.resx @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Group with ID '{0}' not found. + + + Users not found: {0} + + + Group with name '{0}' already exists. + + + Roles not found: {0} + + + System groups cannot be deleted. + + + User '{0}' is not a member of group '{1}'. + + + Users cannot be removed from a default group. + + + System groups cannot be modified. + + + Role not found. + + + Roles not found. + + + System roles cannot be modified. + + + Cannot rename a role to a system role's name. + + + Cannot create a role using a system role's name. + + + System roles cannot be deleted. + + + System role permissions are managed by the framework and cannot be modified. + + + Operation failed. + + + Current session is not an impersonation session. + + + Original actor not found. + + + Impersonation grant not found. + + + Cross-tenant impersonation is restricted to platform operators. + + + Cannot impersonate yourself. + + + End current impersonation before starting a new one. + + + Target user not found. + + + User not found. + + + User {0} not found. + + + Current password is incorrect. + + + Failed to generate authenticator key. + + + The authenticator code is invalid. + + + Invalid refresh token. + + + Session has been revoked. + + + Access token subject mismatch. + + + two_factor_required: An authenticator code is required to complete sign-in. + + + two_factor_invalid: The authenticator code is invalid or expired. + + + Account is temporarily locked due to too many failed login attempts. Try again later. + + + Refresh token is invalid or expired. + + + User is deactivated. + + + Email not confirmed. + + + Tenant {0} is deactivated. + + + Tenant {0} validity has expired. + + + Error resetting password. + + + Failed to change password. + + + Update profile failed. + + + Update profile image failed. + + + An error occurred while confirming E-Mail. + + + An error occurred while confirming {0}. + + + An error occurred while confirming the email for {0}: {1} + + + The email for {0} is already confirmed. + + + An error occurred while confirming phone number. + + + An error occurred while confirming phone number {0}. + + + Email claim is required for external authentication. + + + Failed to create user from external principal. + + + Passwords do not match. + + + Unable to register the user. + + + Administrators cannot remove their own admin role. + + + The root tenant administrator cannot be demoted. + + + Tenant must retain at least one administrator. + + + Only administrators can change user status. + + + Users cannot deactivate themselves. + + + Administrators cannot be deactivated. + + + Tenant must have at least one active administrator. + + + Toggle status failed. + + + Invalid credentials. + + + Cannot view sessions for another user + + + Cannot revoke session for another user + + + Cannot revoke sessions for another user + + + RoleManager<FshRole> not resolved. Check Identity registration. + + + Role store not configured. Ensure .AddRoles<FshRole>() and EF stores. + + + Method reserved for in-scope initialization + + diff --git a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj index 2ef3b71607..7f3773dcfe 100644 --- a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj +++ b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj @@ -3,7 +3,7 @@ FSH.Modules.Identity FSH.Modules.Identity - $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227 + $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227;S2094 diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs index a403a2363e..76e07c0ac0 100644 --- a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -72,7 +73,11 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo throw new CustomException( "two_factor_required: An authenticator code is required to complete sign-in.", errors: null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Identity.TwoFactorRequired", + ResourceSource = typeof(IdentityResources), + }; } var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -83,7 +88,11 @@ private async Task VerifyTwoFactorOrThrowAsync(FshUser user, string? twoFactorCo if (!valid) { _logger.LogWarning("Invalid two-factor code for user {UserId}", user.Id); - throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired."); + throw new UnauthorizedException("two_factor_invalid: The authenticator code is invalid or expired.") + { + MessageKey = "Identity.TwoFactorInvalid", + ResourceSource = typeof(IdentityResources), + }; } } @@ -116,7 +125,11 @@ public async Task StoreRefreshTokenAsync(string subject, string refreshToken, Da if (updated == 0) { - throw new UnauthorizedException("user not found"); + throw new UnauthorizedException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; } if (_logger.IsEnabled(LogLevel.Debug)) @@ -199,7 +212,11 @@ private async Task FindAndValidateUserByCredentialsAsync(string email, throw new CustomException( "Account is temporarily locked due to too many failed login attempts. Try again later.", errors: null, - HttpStatusCode.Locked); + HttpStatusCode.Locked) + { + MessageKey = "Identity.AccountLocked", + ResourceSource = typeof(IdentityResources), + }; } if (!await _userManager.CheckPasswordAsync(user, password)) @@ -243,7 +260,11 @@ private async Task FindUserByRefreshTokenAsync(string refreshToken, str if (user is null) { _logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId); - throw new UnauthorizedException("refresh token is invalid or expired"); + throw new UnauthorizedException("refresh token is invalid or expired") + { + MessageKey = "Identity.RefreshTokenInvalidOrExpired", + ResourceSource = typeof(IdentityResources), + }; } return user; @@ -257,7 +278,11 @@ private void ValidateRefreshTokenExpiry(FshUser user) _logger.LogWarning( "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}", user.Id, user.RefreshTokenExpiryTime, now); - throw new UnauthorizedException("refresh token is invalid or expired"); + throw new UnauthorizedException("refresh token is invalid or expired") + { + MessageKey = "Identity.RefreshTokenInvalidOrExpired", + ResourceSource = typeof(IdentityResources), + }; } } @@ -265,12 +290,20 @@ private static void ValidateUserStatus(FshUser user) { if (!user.IsActive) { - throw new UnauthorizedException("user is deactivated"); + throw new UnauthorizedException("user is deactivated") + { + MessageKey = "Identity.UserDeactivated", + ResourceSource = typeof(IdentityResources), + }; } if (!user.EmailConfirmed) { - throw new UnauthorizedException("email not confirmed"); + throw new UnauthorizedException("email not confirmed") + { + MessageKey = "Identity.EmailNotConfirmed", + ResourceSource = typeof(IdentityResources), + }; } } @@ -283,14 +316,24 @@ private void ValidateTenantStatus(AppTenantInfo tenant) if (!tenant.IsActive) { - throw new UnauthorizedException($"tenant {tenant.Id} is deactivated"); + throw new UnauthorizedException($"tenant {tenant.Id} is deactivated") + { + MessageKey = "Identity.TenantDeactivated", + MessageArgs = [tenant.Id], + ResourceSource = typeof(IdentityResources), + }; } // Honor the billing grace period: a lapsed tenant can still authenticate until // ValidUpto + grace (matching the request-time guard in MultitenancyModule). if (_timeProvider.GetUtcNow().UtcDateTime > tenant.ValidUpto.AddDays(_gracePeriodDays)) { - throw new UnauthorizedException($"tenant {tenant.Id} validity has expired"); + throw new UnauthorizedException($"tenant {tenant.Id} validity has expired") + { + MessageKey = "Identity.TenantValidityExpired", + MessageArgs = [tenant.Id], + ResourceSource = typeof(IdentityResources), + }; } } @@ -301,11 +344,11 @@ private async Task> BuildUserClaimsAsync(FshUser user, string tenant return claims; } - private static List CreateBasicClaims(FshUser user, string tenantId) + internal static List CreateBasicClaims(FshUser user, string tenantId) { var fullName = $"{user.FirstName} {user.LastName}".Trim(); - return - [ + var claims = new List + { new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // RFC 7519 short-form sub/name/email emitted alongside legacy ClaimTypes.* so JWT consumers read them per spec. // `name` is published explicitly because the default outbound map turns ClaimTypes.Name into `unique_name`, not `name`. @@ -320,7 +363,17 @@ private static List CreateBasicClaims(FshUser user, string tenantId) new(ClaimTypes.Surname, user.LastName ?? string.Empty), new(ClaimConstants.Tenant, tenantId), new(ClaimConstants.ImageUrl, user.ImageUrl?.ToString() ?? string.Empty) - ]; + }; + + // OIDC-standard `locale` claim, emitted ONLY when the user explicitly chose a language. + // A null/blank Locale emits no claim so the culture-resolution chain falls to Accept-Language + // rather than forcing the deployment default onto users who never picked one. + if (!string.IsNullOrWhiteSpace(user.Locale)) + { + claims.Add(new Claim("locale", user.Locale)); + } + + return claims; } private async Task AddRoleClaimsAsync(List claims, FshUser user, CancellationToken ct) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index c96c90384b..d5e45edafc 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -34,7 +35,11 @@ public async Task GetAsync(string userId, CancellationToken cancellatio .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; return new UserDto { @@ -48,6 +53,7 @@ public async Task GetAsync(string userId, CancellationToken cancellatio EmailConfirmed = user.EmailConfirmed, PhoneNumber = user.PhoneNumber, TwoFactorEnabled = user.TwoFactorEnabled, + Locale = user.Locale, }; } @@ -75,11 +81,15 @@ public async Task> GetListAsync(CancellationToken cancellationToke return result; } - public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default) + public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default) { var user = await userManager.FindByIdAsync(userId); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; Uri imageUri = user.ImageUrl ?? null!; // image is optional: text-only edits forward a null FileUploadRequest, so guard before @@ -101,6 +111,13 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, user.FirstName = firstName; user.LastName = lastName; + // Null locale means "not provided by this update" — preserve the existing value so a + // text-only profile edit never clears a language the user already chose. + if (locale is not null) + { + user.Locale = locale; + } + string? currentPhoneNumber = await userManager.GetPhoneNumberAsync(user); if (phoneNumber != currentPhoneNumber) { @@ -112,7 +129,11 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, if (!result.Succeeded) { - throw new CustomException("Update profile failed"); + throw new CustomException("Update profile failed") + { + MessageKey = "Identity.UpdateProfileFailed", + ResourceSource = typeof(IdentityResources), + }; } } @@ -120,7 +141,11 @@ public async Task SetImageUrlAsync(string userId, string? imageUrl, Cancellation { EnsureValidTenant(); var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; user.ImageUrl = string.IsNullOrWhiteSpace(imageUrl) ? null @@ -129,7 +154,11 @@ public async Task SetImageUrlAsync(string userId, string? imageUrl, Cancellation var result = await userManager.UpdateAsync(user); if (!result.Succeeded) { - throw new CustomException("Update profile image failed"); + throw new CustomException("Update profile image failed") + { + MessageKey = "Identity.UpdateProfileImageFailed", + ResourceSource = typeof(IdentityResources), + }; } await signInManager.RefreshSignInAsync(user); @@ -157,7 +186,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.cs index e11963512f..827eab17bc 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.cs @@ -55,8 +55,8 @@ public Task> GetListAsync(CancellationToken cancellationToken) public Task GetCountAsync(CancellationToken cancellationToken) => profileService.GetCountAsync(cancellationToken); - public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, CancellationToken cancellationToken = default) - => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage, cancellationToken); + public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage, string? locale, CancellationToken cancellationToken = default) + => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage, locale, cancellationToken); public Task ExistsWithEmailAsync(string email, string? exceptId = null, CancellationToken cancellationToken = default) => profileService.ExistsWithEmailAsync(email, exceptId, cancellationToken); diff --git a/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs index 2650be91f0..ac9698d198 100644 --- a/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs +++ b/src/Tests/Auditing.Tests/Contracts/ExceptionSeverityClassifierTests.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; namespace Auditing.Tests.Contracts; @@ -140,6 +141,45 @@ public void Classify_Should_ReturnInformation_For_DerivedOperationCanceledExcept result.ShouldBe(AuditSeverity.Information); } + // The localization work introduced LocalizedUnauthorizedAccessException specifically so that + // subclassing the BCL type — rather than swapping it for a CustomException — keeps this + // classifier mapping unauthorized access to Warning. That intent lived only in a code + // comment: changing the base type would silently reclassify every unauthorized access as + // Error and no test would have noticed. This is the test that notices. + [Fact] + public void Classify_Should_ReturnWarning_For_LocalizedUnauthorizedAccessException() + { + // Arrange + var exception = new LocalizedUnauthorizedAccessException("Authentication failed.") + { + MessageKey = "Error.AuthenticationFailed", + }; + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Warning); + } + + // The KeyNotFound counterpart lands on Error either way; pinned so the classification is + // stated rather than left to be derived from the switch's default arm. + [Fact] + public void Classify_Should_ReturnError_For_LocalizedKeyNotFoundException() + { + // Arrange + var exception = new LocalizedKeyNotFoundException("Not found.") + { + MessageKey = "Error.NotFound", + }; + + // Act + var result = ExceptionSeverityClassifier.Classify(exception); + + // Assert + result.ShouldBe(AuditSeverity.Error); + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1032:Implement standard exception constructors", Justification = "Test-only exception class")] private sealed class CustomCanceledException : OperationCanceledException { diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs new file mode 100644 index 0000000000..c415d51350 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Linq; + +namespace Framework.Tests.Localization; + +// Guards against an English key missing from the .pt-BR catalog (a silent English fallback shipped as +// "translated"). Enumerates each culture's own embedded resx (includeParentCultures: false) and +// asserts identical key sets. +public sealed class SharedResourcesKeyParityTests +{ + private static List KeysFor(string culture) + { + var localizer = SharedResourcesLocalizerFactory.Create(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // SharedResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // SharedResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } +} diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs new file mode 100644 index 0000000000..c860d93d65 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizationTests.cs @@ -0,0 +1,51 @@ +using System.Globalization; +using FSH.Framework.Core.Localization; +using FSH.Framework.Web.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Framework.Tests.Localization; + +// Proves the whole resx wiring: SharedResources marker + co-located resx + ResourcesPath="" in +// AddHeroLocalization resolve to the embedded catalog under the current UI culture. If the manifest +// name or ResourcesPath is wrong, ResourceNotFound flips true and IStringLocalizer leaks the raw key. +public sealed class SharedResourcesLocalizationTests +{ + private static IStringLocalizer BuildLocalizer() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddHeroLocalization(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + // Catalogs are named for specific cultures (SharedResources.pt-BR.resx), matching the front-end. + // The consequence is deliberate and pinned here: only pt-BR is served Portuguese. A bare `pt` or + // an unsupported variant like pt-PT walks its parent chain, finds no catalog of its own and lands + // on the neutral (English) one, rather than being silently handed Brazilian strings. + [Theory] + [InlineData("pt-BR", "Não encontrado")] // specific pt-BR resolves directly + [InlineData("pt", "Not Found")] // bare pt has no catalog -> neutral English + [InlineData("pt-PT", "Not Found")] // unsupported variant -> neutral English, NOT pt-BR + [InlineData("en-US", "Not Found")] // en-US falls back to the neutral (default) catalog + public void Localizer_resolves_error_key_per_culture(string culture, string expected) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + var localized = localizer["Error.NotFound"]; + + localized.ResourceNotFound.ShouldBeFalse( + $"resx for '{culture}' did not resolve 'Error.NotFound' — check ResourcesPath/resx manifest name."); + localized.Value.ShouldBe(expected); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..150d667725 --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,27 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Framework.Tests.Localization; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx). Shared by the handler and validator tests +// so they exercise the actual catalog resolution rather than a stub. +internal static class SharedResourcesLocalizerFactory +{ + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider(); + } + + public static IStringLocalizer Create() => + BuildProvider().GetRequiredService>(); + + // The GlobalExceptionHandler resolves module-catalog keys through IStringLocalizerFactory + // (via CustomException.ResourceSource); tests build a real factory bound to the same setup. + public static IStringLocalizerFactory CreateFactory() => + BuildProvider().GetRequiredService(); +} diff --git a/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs new file mode 100644 index 0000000000..4bfb0594ce --- /dev/null +++ b/src/Tests/Framework.Tests/Localization/UserLocaleRequestCultureProviderTests.cs @@ -0,0 +1,138 @@ +using System.Globalization; +using System.Security.Claims; +using FSH.Framework.Web.Localization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Framework.Tests.Localization; + +public sealed class UserLocaleRequestCultureProviderTests +{ + private static IOptions BuildOptions() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var services = new ServiceCollection(); + services.AddHeroLocalization(configuration); + return services.BuildServiceProvider().GetRequiredService>(); + } + + private static DefaultHttpContext BuildContext(string? claim, string? header, string? query) + { + var context = new DefaultHttpContext(); + if (claim is not null) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim("locale", claim)], "test")); + } + + if (header is not null) + { + context.Request.Headers.AcceptLanguage = header; + } + + if (query is not null) + { + context.Request.QueryString = new QueryString($"?culture={query}"); + } + + return context; + } + + // Full culture-provider chain: Query -> user locale claim -> Accept-Language -> configured default -> en-US. + [Theory] + [InlineData("pt-BR", "en-US", null, "pt-BR")] // supported claim wins over the header + [InlineData(null, "pt-BR", null, "pt-BR")] // header used when there is no claim + [InlineData(null, null, null, "en-US")] // nothing set -> default fallback + [InlineData("xx-YY", "pt-BR", null, "pt-BR")] // unsupported claim ignored -> falls to header + [InlineData(null, "pt-BR", "en-US", "en-US")] // explicit query override wins over everything + [InlineData(null, "pt", null, "en-US")] // bare pt is not a supported tag -> default + [InlineData(null, "pt-PT", null, "en-US")] // unsupported variant -> default, never pt-BR + public async Task Resolves_expected_culture_through_chain(string? claim, string? header, string? query, string expected) + { + var options = BuildOptions(); + var context = BuildContext(claim, header, query); + + string? resolved = null; + var middleware = new RequestLocalizationMiddleware( + _ => { resolved = CultureInfo.CurrentUICulture.Name; return Task.CompletedTask; }, + options, + NullLoggerFactory.Instance); + + var previous = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + try + { + await middleware.Invoke(context); + } + finally + { + CultureInfo.CurrentCulture = previous.Item1; + CultureInfo.CurrentUICulture = previous.Item2; + } + + resolved.ShouldBe(expected); + } + + // Localization negotiates the UI culture ONLY. CurrentCulture must stay invariant no matter what + // the request asks for, so no endpoint's ToString()/Parse()/interpolation shifts per request. Runs + // the real RequestLocalizationMiddleware, because this property comes out of the interaction + // between DefaultRequestCulture and a null SupportedCultures, not out of our provider. + [Theory] + [InlineData("pt-BR", null, null, "pt-BR")] // claim + [InlineData(null, "pt-BR", null, "pt-BR")] // header + [InlineData(null, null, "pt-BR", "pt-BR")] // query override + [InlineData(null, null, null, "en-US")] // nothing set + public async Task Formatting_culture_stays_invariant_while_ui_culture_negotiates( + string? claim, string? header, string? query, string expectedUiCulture) + { + var options = BuildOptions(); + var context = BuildContext(claim, header, query); + + string? formattingCulture = null; + string? uiCulture = null; + var middleware = new RequestLocalizationMiddleware( + _ => + { + formattingCulture = CultureInfo.CurrentCulture.Name; + uiCulture = CultureInfo.CurrentUICulture.Name; + return Task.CompletedTask; + }, + options, + NullLoggerFactory.Instance); + + var previous = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); + try + { + CultureInfo.CurrentCulture = new CultureInfo("pt-BR"); + await middleware.Invoke(context); + } + finally + { + CultureInfo.CurrentCulture = previous.Item1; + CultureInfo.CurrentUICulture = previous.Item2; + } + + uiCulture.ShouldBe(expectedUiCulture); + formattingCulture.ShouldBe( + string.Empty, + "CurrentCulture must be the invariant culture; a negotiated formatting culture would shift " + + "number and date rendering for every endpoint in the request."); + } + + // The custom provider in isolation: emit the claim only when supported, otherwise fall through (null). + [Theory] + [InlineData("pt-BR", "pt-BR")] + [InlineData("en-US", "en-US")] + [InlineData("xx-YY", null)] + [InlineData(null, null)] + public async Task Provider_returns_claim_only_when_supported(string? claim, string? expected) + { + var context = BuildContext(claim, header: null, query: null); + var result = await new UserLocaleRequestCultureProvider().DetermineProviderCultureResult(context); + (result?.Cultures[0].Value).ShouldBe(expected); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs new file mode 100644 index 0000000000..7027fd9116 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using System.Net; +using System.Text; +using System.Text.Json; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Exceptions; +using Framework.Tests.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Framework.Tests.Web; + +// Handler-level (Docker-free) proof that GlobalExceptionHandler localizes the ProblemDetails body +// from the shared resx under the ambient UI culture. Covers both the framework branches (raw +// KeyNotFoundException/InvalidOperationException) and the CustomException branch, whose Title now +// comes from the status-mapped catalog key and whose Detail is resolved from MessageKey (falling +// back to the English Message when no key is set). +public sealed class GlobalExceptionHandlerLocalizationTests +{ + private static async Task<(string? Title, string? Detail)> HandleAsync(Exception exception, string culture) + { + var (title, detail, _) = await HandleWithCodeAsync(exception, culture); + return (title, detail); + } + + private static async Task<(string? Title, string? Detail, string? Code)> HandleWithCodeAsync(Exception exception, string culture) + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + + var context = new DefaultHttpContext(); + context.Request.Path = "/api/v1/test"; + using var body = new MemoryStream(); + context.Response.Body = body; + + var handler = new GlobalExceptionHandler( + NullLogger.Instance, + SharedResourcesLocalizerFactory.Create(), + SharedResourcesLocalizerFactory.CreateFactory()); + await handler.TryHandleAsync(context, exception, CancellationToken.None); + + var json = Encoding.UTF8.GetString(body.ToArray()); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var title = root.TryGetProperty("title", out var t) ? t.GetString() : null; + var detail = root.TryGetProperty("detail", out var d) ? d.GetString() : null; + var code = root.TryGetProperty("code", out var c) ? c.GetString() : null; + return (title, detail, code); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task NotFound_title_is_localized(string culture, string expected) + { + var (title, _) = await HandleAsync(new KeyNotFoundException("missing"), culture); + title.ShouldBe(expected); + } + + [Theory] + [InlineData("pt-BR", "Ocorreu um erro inesperado")] + [InlineData("en-US", "An unexpected error occurred")] + public async Task Unexpected_title_is_localized(string culture, string expected) + { + var (title, _) = await HandleAsync(new InvalidOperationException("boom"), culture); + title.ShouldBe(expected); + } + + // CustomException Title is now the status-mapped catalog key (404 -> Error.NotFound), localized. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task CustomException_title_is_localized_by_status(string culture, string expected) + { + var (title, _) = await HandleAsync(new NotFoundException("some entity was not found"), culture); + title.ShouldBe(expected); + } + + // Detail resolves from MessageKey under the request culture (using an existing Core key). + [Theory] + [InlineData("pt-BR", "Não autorizado")] + [InlineData("en-US", "Unauthorized")] + public async Task CustomException_detail_is_localized_from_key(string culture, string expected) + { + var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + // No MessageKey: Detail falls back to the literal (English) Message regardless of culture (non-breaking). + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task CustomException_detail_falls_back_to_message_without_key(string culture) + { + var (_, detail) = await HandleAsync(new NotFoundException("Plain English detail."), culture); + detail.ShouldBe("Plain English detail."); + } + + // Parameterless UnauthorizedException carries Error.AuthenticationFailed, so generic auth failures + // localize their Detail without any call-site key (English fallback stays "Authentication failed."). + [Theory] + [InlineData("pt-BR", "Falha na autenticação.")] + [InlineData("en-US", "Authentication failed.")] + public async Task Parameterless_unauthorized_detail_is_localized(string culture, string expected) + { + var (_, detail) = await HandleAsync(new UnauthorizedException(), culture); + detail.ShouldBe(expected); + } + + // Unknown MessageKey: ResourceNotFound path falls back to the English Message, never leaks the raw key. + [Fact] + public async Task CustomException_detail_falls_back_when_key_missing() + { + var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var (_, detail) = await HandleAsync(exception, "pt-BR"); + detail.ShouldBe("English fallback detail."); + } + + // BCL-subclass exceptions (kept as their base type so audit severity classification is unaffected) + // still localize their Detail from MessageKey through the shared handler path. + [Theory] + [InlineData("pt-BR", "Falha na autenticação.")] + [InlineData("en-US", "Authentication failed.")] + public async Task LocalizedUnauthorizedAccess_detail_is_localized(string culture, string expected) + { + var exception = new LocalizedUnauthorizedAccessException("english fallback") + { + MessageKey = "Error.AuthenticationFailed", + }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task LocalizedKeyNotFound_detail_is_localized(string culture, string expected) + { + var exception = new LocalizedKeyNotFoundException("english fallback") + { + MessageKey = "Error.NotFound", + }; + var (_, detail) = await HandleAsync(exception, culture); + detail.ShouldBe(expected); + } + + // No MessageKey on a localized subclass → Detail falls back to the literal (English) message. + [Fact] + public async Task LocalizedUnauthorizedAccess_without_key_falls_back_to_message() + { + var (_, detail) = await HandleAsync(new LocalizedUnauthorizedAccessException("Plain English."), "pt-BR"); + detail.ShouldBe("Plain English."); + } + + // Detail is prose under the request culture, so the MessageKey travels as a stable "code" extension: + // clients branch on the code instead of matching localized text. Same key in every culture. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task CustomException_surfaces_the_message_key_as_code(string culture) + { + var exception = new UnauthorizedException("english fallback") { MessageKey = "Error.Unauthorized" }; + var (_, _, code) = await HandleWithCodeAsync(exception, culture); + code.ShouldBe("Error.Unauthorized"); + } + + // A localized BCL subclass carries its key through the same path. + [Fact] + public async Task LocalizedKeyNotFound_surfaces_the_message_key_as_code() + { + var exception = new LocalizedKeyNotFoundException("english fallback") { MessageKey = "Error.NotFound" }; + var (_, _, code) = await HandleWithCodeAsync(exception, "pt-BR"); + code.ShouldBe("Error.NotFound"); + } + + // No key → no code property at all, rather than a null or an invented one. + [Fact] + public async Task Exception_without_key_omits_the_code() + { + var (_, _, code) = await HandleWithCodeAsync(new NotFoundException("Plain English detail."), "pt-BR"); + code.ShouldBeNull(); + } + + // An unknown key still travels as the code even though Detail fell back to English: the code is the + // contract, the resx lookup is presentation. + [Fact] + public async Task Unknown_key_still_surfaces_as_code() + { + var exception = new NotFoundException("English fallback detail.") { MessageKey = "Does.Not.Exist" }; + var (_, detail, code) = await HandleWithCodeAsync(exception, "pt-BR"); + detail.ShouldBe("English fallback detail."); + code.ShouldBe("Does.Not.Exist"); + } + + // A raw BCL exception (no ILocalizableMessage) keeps its message and gets no code. + [Fact] + public async Task Raw_key_not_found_has_no_code() + { + var (_, detail, code) = await HandleWithCodeAsync(new KeyNotFoundException("missing"), "pt-BR"); + detail.ShouldBe("missing"); + code.ShouldBeNull(); + } + + // A 409 must not be titled "an unexpected error occurred". Before Error.Conflict existed the + // status-to-key map fell through to Error.Unexpected — which RESOLVES, so the type-name fallback + // never fired and every conflict in the API reported a title contradicting its own status and its + // own detail. There are 41 Conflict throw sites across Billing and Catalog. + [Theory] + [InlineData("en-US", "Conflict")] + [InlineData("pt-BR", "Conflito")] + public async Task Conflict_is_titled_as_a_conflict_not_as_unexpected(string culture, string expected) + { + var exception = new CustomException("Brand name already taken.", [], HttpStatusCode.Conflict); + + var (title, _) = await HandleAsync(exception, culture); + + title.ShouldBe(expected); + } + + // A status with no title of its own keeps the pre-localization behaviour — the exception type + // name — instead of claiming the error was unexpected. Status-consistent beats confidently wrong. + [Theory] + [InlineData("en-US")] + [InlineData("pt-BR")] + public async Task Untranslated_status_falls_back_to_the_exception_type_name(string culture) + { + var exception = new CustomException("Mailbox is locked.", [], HttpStatusCode.Locked); + + var (title, _) = await HandleAsync(exception, culture); + + title.ShouldBe(nameof(CustomException)); + } +} diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs index 5355719f66..8d941ac71a 100644 --- a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerTests.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Web.Exceptions; +using Framework.Tests.Localization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; using System.Net; @@ -14,7 +15,10 @@ private static async Task HandleAsync(Exception exception) context.Request.Path = "/api/v1/identity/forgot-password"; context.Response.Body = new MemoryStream(); - var handler = new GlobalExceptionHandler(NullLogger.Instance); + var handler = new GlobalExceptionHandler( + NullLogger.Instance, + SharedResourcesLocalizerFactory.Create(), + SharedResourcesLocalizerFactory.CreateFactory()); await handler.TryHandleAsync(context, exception, CancellationToken.None); return context; } diff --git a/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs b/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs new file mode 100644 index 0000000000..2145754fba --- /dev/null +++ b/src/Tests/Identity.Tests/Data/IdentityDbContextModelTests.cs @@ -0,0 +1,56 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Shared.Persistence; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Identity.Tests.Data; + +public class IdentityDbContextModelTests +{ + private static IdentityDbContext CreateContext() + { + var accessor = Substitute.For>(); + accessor.MultiTenantContext.Returns(new MultiTenantContext(new AppTenantInfo())); + + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=arch;Database=arch;Username=arch;Password=arch") + .Options; + + var settings = Options.Create(new DatabaseOptions + { + Provider = "postgresql", + ConnectionString = string.Empty, + MigrationsAssembly = "FSH.Starter.Migrations.PostgreSQL", + }); + + var environment = Substitute.For(); + environment.EnvironmentName.Returns("Production"); + + return new IdentityDbContext(accessor, options, settings, environment); + } + + // User.Locale holds a BCP-47 tag, which is short and bounded. Unbounded `text` + // invites arbitrary input at the storage layer for a value the write boundary + // already restricts to SupportedCultures.Tags. 10 covers the longest form the + // platform could offer (language-script-region, e.g. zh-Hant-TW). + [Fact] + public void User_Locale_Is_Bounded_To_A_Bcp47_Tag_Length() + { + using var context = CreateContext(); + + var locale = context.Model.FindEntityType(typeof(FshUser))?.FindProperty(nameof(FshUser.Locale)); + + locale.ShouldNotBeNull(); + locale!.GetMaxLength().ShouldBe( + 10, + "an unbounded locale column accepts arbitrary input for a value that is always a short BCP-47 tag"); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs new file mode 100644 index 0000000000..10381c4c85 --- /dev/null +++ b/src/Tests/Identity.Tests/Handlers/StartImpersonationCommandHandlerTests.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using FSH.Framework.Core.Context; +using FSH.Framework.Shared.Constants; +using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Contracts.v1.Impersonation; +using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using FSH.Modules.Identity.Features.v1.Impersonation.StartImpersonation; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.IdentityModel.Tokens.Jwt; + +namespace Identity.Tests.Handlers; + +/// +/// The impersonation token must NOT carry the target user's `locale` claim — language is a +/// presentation concern, so the operator keeps reading in their own language. +/// +public sealed class StartImpersonationCommandHandlerTests +{ + private const string TenantId = "codefi"; + private const string TargetUserId = "target-user"; + + private readonly IIdentityService _identityService = Substitute.For(); + private readonly ITokenService _tokenService = Substitute.For(); + private readonly ISecurityAudit _securityAudit = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IRequestContext _requestContext = Substitute.For(); + private readonly IImpersonationGrantService _grantService = Substitute.For(); + + private StartImpersonationCommandHandler CreateSut() => + new(_identityService, _tokenService, _securityAudit, _currentUser, _requestContext, + _grantService, TimeProvider.System, Substitute.For>()); + + [Fact] + public async Task Handle_strips_locale_but_preserves_identity_claims_and_injects_actor() + { + // Arrange — an authenticated operator in the same tenant as the target. + var actorUserId = Guid.NewGuid(); + _currentUser.IsAuthenticated().Returns(true); + _currentUser.GetUserId().Returns(actorUserId); + _currentUser.GetTenant().Returns(TenantId); + _currentUser.Name.Returns("operator"); + _currentUser.GetUserClaims().Returns(new List()); + + // A realistic target claim set: the persisted `locale` must be dropped, but every other + // identity claim (name, role, subject, tenant) must survive into the impersonation token. + var targetClaims = new List + { + new(JwtRegisteredClaimNames.Jti, "orig-jti"), + new(JwtRegisteredClaimNames.Sub, TargetUserId), + new(ClaimConstants.Tenant, TenantId), + new(ClaimTypes.Name, "Target User"), + new(ClaimTypes.Role, "Admin"), + new("locale", "pt-BR"), + }; + _identityService + .BuildClaimsForUserAsync(TargetUserId, TenantId, Arg.Any()) + .Returns(((string, IEnumerable)?)(TargetUserId, targetClaims)); + + IEnumerable? issuedClaims = null; + _tokenService + .IssueAccessOnlyAsync( + Arg.Any(), + Arg.Do>(c => issuedClaims = c), + Arg.Any(), + Arg.Any()) + .Returns(("access-token", DateTime.UtcNow.AddMinutes(15))); + + var sut = CreateSut(); + + // Act + await sut.Handle(new StartImpersonationCommand(TargetUserId, TenantId, "reason", 15), CancellationToken.None); + + // Assert + issuedClaims.ShouldNotBeNull(); + var issued = issuedClaims.ToList(); + + // (a) locale is stripped — the operator keeps reading in their own language. + issued.ShouldNotContain(c => c.Type == "locale"); + + // (b) every NON-locale identity claim survives — a mutation that over-strips (e.g. drops Name, + // role, sub or tenant) must fail here. + issued.ShouldContain(c => c.Type == ClaimTypes.Name && c.Value == "Target User"); + issued.ShouldContain(c => c.Type == ClaimTypes.Role && c.Value == "Admin"); + issued.ShouldContain(c => c.Type == JwtRegisteredClaimNames.Sub && c.Value == TargetUserId); + issued.ShouldContain(c => c.Type == ClaimConstants.Tenant && c.Value == TenantId); + + // (c) RFC 8693 actor claims are injected so the token records who is acting. + issued.ShouldContain(c => c.Type == ClaimConstants.ActorSubject && c.Value == actorUserId.ToString()); + issued.ShouldContain(c => c.Type == ClaimConstants.ActorTenant && c.Value == TenantId); + + // (d) the jti is swapped: the target's original jti is gone and exactly one fresh, non-empty + // jti is present (so the persisted grant row and the JWT share a new identifier). + issued.ShouldNotContain(c => c.Type == JwtRegisteredClaimNames.Jti && c.Value == "orig-jti"); + var jti = issued.Single(c => c.Type == JwtRegisteredClaimNames.Jti); + jti.Value.ShouldNotBe("orig-jti"); + jti.Value.ShouldNotBeNullOrWhiteSpace(); + } +} diff --git a/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs b/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs index f89478916a..2a9e25d006 100644 --- a/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs +++ b/src/Tests/Identity.Tests/Handlers/UpdateUserCommandHandlerTests.cs @@ -39,7 +39,8 @@ await _userService.Received(1).UpdateAsync( command.LastName ?? string.Empty, command.PhoneNumber ?? string.Empty, command.Image!, - command.DeleteCurrentImage); + command.DeleteCurrentImage, + command.Locale); } [Fact] @@ -66,7 +67,8 @@ await _userService.Received(1).UpdateAsync( string.Empty, string.Empty, null!, - true); + true, + command.Locale); } [Fact] @@ -83,7 +85,7 @@ public async Task Handle_Should_ThrowException_When_UserServiceThrows() // Arrange var command = _fixture.Create(); var expectedExceptionMessage = "Update failed"; - _userService.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _userService.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(x => throw new InvalidOperationException(expectedExceptionMessage)); // Act & Assert diff --git a/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs b/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs new file mode 100644 index 0000000000..ab8a29c746 --- /dev/null +++ b/src/Tests/Identity.Tests/Services/CreateBasicClaimsTests.cs @@ -0,0 +1,38 @@ +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Services; + +namespace Identity.Tests.Services; + +/// +/// The JWT carries the OIDC-standard `locale` claim only when the user explicitly chose a language; +/// an unset locale emits no claim so culture resolution can fall through to Accept-Language. +/// +public sealed class CreateBasicClaimsTests +{ + private static FshUser User(string? locale) => + new() { Id = "u1", Email = "u@codefi.com.br", UserName = "u", FirstName = "First", LastName = "Last", Locale = locale }; + + [Fact] + public void Emits_locale_claim_when_user_locale_is_set() + { + var claims = IdentityService.CreateBasicClaims(User("pt-BR"), "codefi"); + + claims.Single(c => c.Type == "locale").Value.ShouldBe("pt-BR"); + } + + [Fact] + public void Omits_locale_claim_when_user_locale_is_null() + { + var claims = IdentityService.CreateBasicClaims(User(null), "codefi"); + + claims.Any(c => c.Type == "locale").ShouldBeFalse(); + } + + [Fact] + public void Omits_locale_claim_when_user_locale_is_whitespace() + { + var claims = IdentityService.CreateBasicClaims(User(" "), "codefi"); + + claims.Any(c => c.Type == "locale").ShouldBeFalse(); + } +} diff --git a/src/Tests/Identity.Tests/Services/UserLocaleTests.cs b/src/Tests/Identity.Tests/Services/UserLocaleTests.cs new file mode 100644 index 0000000000..f8a6d1726a --- /dev/null +++ b/src/Tests/Identity.Tests/Services/UserLocaleTests.cs @@ -0,0 +1,95 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Framework.Storage.Services; +using FSH.Framework.Web.Origin; +using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Services; +using Identity.Tests.Support; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Identity.Tests.Services; + +/// +/// Covers the User.Locale foundation: UpdateAsync persists the locale onto the entity and +/// GetAsync projects it back onto the DTO. +/// +public sealed class UserLocaleTests +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IStorageService _storageService; + private readonly IMultiTenantContextAccessor _tenantAccessor; + + public UserLocaleTests() + { + _userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + _signInManager = Substitute.For>( + _userManager, + Substitute.For(), + Substitute.For>(), + Options.Create(new IdentityOptions()), + Substitute.For>>(), + Substitute.For(), + Substitute.For>()); + _signInManager.RefreshSignInAsync(Arg.Any()).Returns(Task.CompletedTask); + _storageService = Substitute.For(); + _tenantAccessor = Substitute.For>(); + } + + private UserProfileService CreateSut() => + new(_userManager, _signInManager, _storageService, _tenantAccessor, + Options.Create(new OriginOptions()), Substitute.For()); + + [Fact] + public async Task UpdateAsync_persists_the_supplied_locale_onto_the_user() + { + // Arrange + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u" }; + _userManager.FindByIdAsync("u1").Returns(user); + _userManager.UpdateAsync(user).Returns(IdentityResult.Success); + var sut = CreateSut(); + + // Act + await sut.UpdateAsync("u1", "First", "Last", string.Empty, null!, false, "pt-BR", CancellationToken.None); + + // Assert + user.Locale.ShouldBe("pt-BR"); + } + + [Fact] + public async Task UpdateAsync_with_null_locale_preserves_the_existing_value() + { + // Arrange — a text-only edit forwards a null locale; the user already chose en-US. + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u", Locale = "en-US" }; + _userManager.FindByIdAsync("u1").Returns(user); + _userManager.UpdateAsync(user).Returns(IdentityResult.Success); + var sut = CreateSut(); + + // Act + await sut.UpdateAsync("u1", "First", "Last", string.Empty, null!, false, null, CancellationToken.None); + + // Assert + user.Locale.ShouldBe("en-US"); + } + + [Fact] + public async Task GetAsync_projects_the_persisted_locale_onto_the_dto() + { + // Arrange + var user = new FshUser { Id = "u1", Email = "u@codefi.com.br", UserName = "u", Locale = "pt-BR" }; + _userManager.Users.Returns(new[] { user }.AsAsyncQueryable()); + var sut = CreateSut(); + + // Act + var dto = await sut.GetAsync("u1", CancellationToken.None); + + // Assert + dto.Locale.ShouldBe("pt-BR"); + } +} diff --git a/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs b/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..fb4b79f15a --- /dev/null +++ b/src/Tests/Identity.Tests/Support/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,18 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Identity.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog so validator +// tests exercise the actual catalog under the ambient UI culture (default culture -> neutral English). +internal static class SharedResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs b/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs new file mode 100644 index 0000000000..39a27f199b --- /dev/null +++ b/src/Tests/Identity.Tests/Support/TestAsyncQueryable.cs @@ -0,0 +1,68 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; + +namespace Identity.Tests.Support; + +/// +/// Minimal in-memory async queryable so services that call EF's FirstOrDefaultAsync +/// (e.g. UserManager.Users.Where(...).FirstOrDefaultAsync) can be unit tested against a +/// mocked UserManager.Users without a database. Standard EF unit-testing scaffold. +/// +internal static class TestAsyncQueryable +{ + public static IQueryable AsAsyncQueryable(this IEnumerable source) => + new TestAsyncEnumerable(source); +} + +internal sealed class TestAsyncQueryProvider : IAsyncQueryProvider +{ + private readonly IQueryProvider _inner; + + internal TestAsyncQueryProvider(IQueryProvider inner) => _inner = inner; + + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + + public object? Execute(Expression expression) => _inner.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Execute(expression); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) + { + // TResult is Task; run the query synchronously through the base provider and wrap the result. + var resultType = typeof(TResult).GetGenericArguments()[0]; + var executionResult = _inner.Execute(expression); + var fromResult = typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(resultType); + return (TResult)fromResult.Invoke(null, new[] { executionResult })!; + } +} + +internal sealed class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable +{ + public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } + + public TestAsyncEnumerable(Expression expression) : base(expression) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); +} + +internal sealed class TestAsyncEnumerator : IAsyncEnumerator +{ + private readonly IEnumerator _inner; + + public TestAsyncEnumerator(IEnumerator inner) => _inner = inner; + + public T Current => _inner.Current; + + public ValueTask MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext()); + + public ValueTask DisposeAsync() + { + _inner.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs index ee98206a87..f486c769c4 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateUserCommandValidatorTests.cs @@ -1,5 +1,8 @@ +using System.Globalization; +using System.Linq; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; using FSH.Modules.Identity.Features.v1.Users.UpdateUser; +using Identity.Tests.Support; using Shouldly; using Xunit; @@ -7,7 +10,21 @@ namespace Identity.Tests.Validators; public sealed class UpdateUserCommandValidatorTests { - private readonly UpdateUserCommandValidator _sut = new(); + private readonly UpdateUserCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); + + private static TResult WithCulture(string culture, Func action) + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo(culture); + return action(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } [Fact] public void Validate_Should_Pass_When_ValidMinimalCommand() @@ -40,10 +57,10 @@ public void Validate_Should_Fail_When_IdIsEmpty() public void Validate_Should_Fail_When_FirstNameExceedsMaxLength() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - FirstName = new string('a', 51) + var command = new UpdateUserCommand + { + Id = "user-123", + FirstName = new string('a', 51) }; // Act @@ -58,10 +75,10 @@ public void Validate_Should_Fail_When_FirstNameExceedsMaxLength() public void Validate_Should_Fail_When_EmailIsInvalid() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - Email = "not-an-email" + var command = new UpdateUserCommand + { + Id = "user-123", + Email = "not-an-email" }; // Act @@ -76,18 +93,69 @@ public void Validate_Should_Fail_When_EmailIsInvalid() public void Validate_Should_Fail_When_DeleteImageAndUploadImage_Simultaneously() { // Arrange - var command = new UpdateUserCommand - { - Id = "user-123", - DeleteCurrentImage = true, - Image = new FSH.Framework.Shared.Storage.FileUploadRequest { FileName = "test.png", Data = [0] } + var command = new UpdateUserCommand + { + Id = "user-123", + DeleteCurrentImage = true, + Image = new FSH.Framework.Shared.Storage.FileUploadRequest { FileName = "test.png", Data = [0] } }; - // Act - var result = _sut.Validate(command); + // Act — pin en-US so the localized message resolves to the neutral (English) catalog. + var result = WithCulture("en-US", () => _sut.Validate(command)); // Assert result.IsValid.ShouldBeFalse(); result.Errors.ShouldContain(e => e.ErrorMessage == "You cannot upload a new image and delete the current one simultaneously."); } + + [Theory] + [InlineData("pt-BR", true)] + [InlineData("en-US", true)] + [InlineData(null, true)] + // Empty/whitespace is treated as "not provided": the .When(!IsNullOrWhiteSpace) guard skips the + // rule, so an empty locale is valid (the caller simply isn't changing it). + [InlineData("", true)] + // Case matters: SupportedCultures.Tags.Contains is ordinal, so a wrong-case tag is rejected — + // pins the ordinal comparison against an accidental case-insensitive refactor. + [InlineData("pt-br", false)] + [InlineData("PT-BR", false)] + [InlineData("xx-YY", false)] + [InlineData("notaculture", false)] + public void Locale_Must_Be_Supported_Or_Null(string? locale, bool expectedValid) + { + // Arrange + var command = new UpdateUserCommand { Id = "user-123", Locale = locale }; + + // Act + var result = _sut.Validate(command); + + // Assert + result.Errors.Any(e => e.PropertyName == nameof(UpdateUserCommand.Locale)).ShouldBe(!expectedValid); + } + + [Fact] + public void UserId_required_message_is_localized_under_ptBR() + { + // Act + var result = WithCulture("pt-BR", () => _sut.Validate(new UpdateUserCommand { Id = "" })); + + // Assert — custom WithMessage resolves from the .pt-BR catalog. + result.Errors.Single(e => e.PropertyName == "Id").ErrorMessage + .ShouldBe("O ID do usuário é obrigatório."); + } + + [Fact] + public void Builtin_validation_message_is_localized_under_ptBR() + { + // Act — FluentValidation resolves built-in messages via CurrentUICulture (ships a pt catalog). + var result = WithCulture("pt-BR", () => + _sut.Validate(new UpdateUserCommand { Id = "user-123", Email = "not-an-email" })); + + // Assert — pin the actual Portuguese text, not merely the absence of the English one. + // "does not contain the English sentence" is satisfied by a blank message, by a raw + // resource key leaking through, and by any wrong-but-non-English string, so it stayed + // green through exactly the failures it existed to catch. + var message = result.Errors.Single(e => e.PropertyName == "Email").ErrorMessage; + message.ShouldBe("'Email' é um endereço de email inválido."); + } } From 7962ea4f2f4c5f39d90d925caf5181c95ac52770 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:43:42 -0300 Subject: [PATCH 02/19] feat(localization): localize module messages and validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module slice of the i18n work (split of #1344 as requested in review). Depends on the framework slice: it uses `SharedResources`, `SupportedCultures` and the `MessageKey` / `MessageArgs` / `ResourceSource` plumbing on `CustomException`, none of which exist on `main` yet. - One `{Module}Resources` catalog per module (en + pt-BR) for Auditing, Billing, Catalog, Chat, Files, Multitenancy, Notifications, Tickets and Webhooks, plus the Identity messages that were not part of the framework slice. - Domain and handler throws carry `MessageKey` and `ResourceSource` so the response body is localized while `Message` stays English for the logs. - Validators resolve their messages through `IStringLocalizer`, with message arguments kept culture-insensitive (`MaxWindowDays` as `int` rather than `MaxWindow.TotalDays` as `double`, which `string.Format` would render with a culture-dependent decimal separator). - `CatalogParityTests` pins key parity and placeholder parity across every catalog, so a missing or malformed translation fails the build rather than reaching a user. - `Tickets.Tests` joins the solution, which previously had no test project. Protected code (Golden Rule #4): this slice touches one file under `src/BuildingBlocks` — `Web/Validation/PagedQueryValidator.cs`. Its constructor now takes `IStringLocalizer` so paging messages localize. All three subclasses live in modules (`GetAuditsQueryValidator`, `GetTenantsQueryValidator`, `SearchUsersQueryValidator`), so the base class travels with its callers rather than breaking them from another PR. Every other `BuildingBlocks` change is in the framework slice. The `SSH.NET` pin (`2026.0.0`) is byte-identical to #1333, so `dotnet restore` passes while that PR is open. --- .../Web/Validation/PagedQueryValidator.cs | 14 +- src/Directory.Packages.props | 7 + src/FSH.Starter.slnx | 1 + .../Auditing/Modules.Auditing/Core/Audit.cs | 11 +- .../GetAuditById/GetAuditByIdQueryHandler.cs | 14 +- .../GetAuditSummaryQueryHandler.cs | 10 +- .../GetAuditSummaryQueryValidator.cs | 11 +- .../v1/GetAudits/GetAuditsQueryHandler.cs | 12 +- .../v1/GetAudits/GetAuditsQueryValidator.cs | 16 +- .../GetAuditsByCorrelationQueryValidator.cs | 8 +- .../GetAuditsByTraceQueryValidator.cs | 8 +- .../GetExceptionAuditsQueryValidator.cs | 8 +- .../GetSecurityAuditsQueryValidator.cs | 8 +- .../Localization/AuditingResources.cs | 4 + .../Localization/AuditingResources.pt-BR.resx | 79 ++++++++ .../Localization/AuditingResources.resx | 79 ++++++++ .../Modules.Auditing/Modules.Auditing.csproj | 3 +- .../GenerateInvoicesCommandHandler.cs | 12 +- .../GetInvoiceByIdQueryHandler.cs | 13 +- .../GetInvoicePdfQueryHandler.cs | 13 +- .../GetInvoices/GetInvoicesQueryHandler.cs | 5 +- .../GetMyInvoicesQueryHandler.cs | 5 +- .../GetPlanTerm/GetPlanTermQueryHandler.cs | 8 +- .../UpdatePlan/UpdatePlanCommandHandler.cs | 8 +- .../AssignSubscriptionCommandHandler.cs | 13 +- .../GetSubscriptionQueryHandler.cs | 5 +- .../CaptureUsageSnapshotsCommandHandler.cs | 5 +- .../GetUsageSnapshotsQueryHandler.cs | 5 +- .../ApproveTopupRequestCommandHandler.cs | 19 +- .../CreateTopupRequestCommandHandler.cs | 5 +- .../GetMyTopupRequestsQueryHandler.cs | 5 +- .../GetMyWallet/GetMyWalletQueryHandler.cs | 5 +- .../GetTopupRequestsQueryHandler.cs | 5 +- .../RejectTopupRequestCommandHandler.cs | 26 ++- .../Localization/BillingResources.cs | 4 + .../Localization/BillingResources.pt-BR.resx | 91 +++++++++ .../Localization/BillingResources.resx | 91 +++++++++ .../Modules.Billing/Modules.Billing.csproj | 2 +- .../Services/BillingService.cs | 34 +++- .../CreateBrand/CreateBrandCommandHandler.cs | 8 +- .../DeleteBrand/DeleteBrandCommandHandler.cs | 8 +- .../GetBrandById/GetBrandByIdQueryHandler.cs | 8 +- .../RestoreBrandCommandHandler.cs | 8 +- .../UpdateBrand/UpdateBrandCommandHandler.cs | 15 +- .../CreateCategoryCommandHandler.cs | 15 +- .../DeleteCategoryCommandHandler.cs | 14 +- .../GetCategoryByIdQueryHandler.cs | 8 +- .../RestoreCategoryCommandHandler.cs | 8 +- .../UpdateCategoryCommandHandler.cs | 27 ++- .../AddProductImageCommandHandler.cs | 8 +- .../AdjustProductStockCommandHandler.cs | 15 +- .../AdjustProductStockCommandValidator.cs | 6 +- .../ChangeProductPriceCommandHandler.cs | 8 +- .../CreateProductCommandHandler.cs | 29 ++- .../DeleteProductCommandHandler.cs | 8 +- .../GetProductByIdQueryHandler.cs | 8 +- .../RemoveProductImageCommandHandler.cs | 15 +- .../ReorderProductImagesCommandHandler.cs | 8 +- .../RestoreProductCommandHandler.cs | 8 +- .../SetProductThumbnailCommandHandler.cs | 15 +- .../UpdateProductCommandHandler.cs | 29 ++- .../Localization/CatalogResources.cs | 4 + .../Localization/CatalogResources.pt-BR.resx | 112 +++++++++++ .../Localization/CatalogResources.resx | 112 +++++++++++ .../Modules.Catalog/Modules.Catalog.csproj | 3 +- .../AddChannelMembersCommandHandler.cs | 15 +- .../ArchiveChannelCommandHandler.cs | 9 +- .../CreateChannelCommandHandler.cs | 2 +- .../DiscoverChannelsQueryHandler.cs | 2 +- .../FindOrCreateDmCommandHandler.cs | 9 +- .../GetChannelByIdQueryHandler.cs | 9 +- .../ListMyChannelsQueryHandler.cs | 2 +- .../MarkChannelReadCommandHandler.cs | 18 +- .../RemoveChannelMemberCommandHandler.cs | 9 +- .../RestoreChannelCommandHandler.cs | 7 +- .../UpdateChannelCommandHandler.cs | 9 +- .../v1/Internal/ChannelAuthorization.cs | 13 +- .../DeleteMessageCommandHandler.cs | 15 +- .../EditMessage/EditMessageCommandHandler.cs | 15 +- .../GetPinnedMessagesQueryHandler.cs | 9 +- .../ListChannelMessagesQueryHandler.cs | 9 +- .../ListMessageRepliesQueryHandler.cs | 15 +- .../PinMessage/PinMessageCommandHandler.cs | 15 +- .../SendMessage/SendMessageCommandHandler.cs | 27 ++- .../SendMessageCommandValidator.cs | 6 +- .../UnpinMessageCommandHandler.cs | 15 +- .../AddReaction/AddReactionCommandHandler.cs | 15 +- .../RemoveReactionCommandHandler.cs | 15 +- .../v1/Search/SearchMessagesQueryHandler.cs | 2 +- .../Localization/ChatResources.cs | 4 + .../Localization/ChatResources.pt-BR.resx | 91 +++++++++ .../Localization/ChatResources.resx | 91 +++++++++ .../Chat/Modules.Chat/Modules.Chat.csproj | 3 +- .../Files/Modules.Files/Domain/FileAsset.cs | 15 +- .../ChangeFileVisibilityCommandHandler.cs | 26 ++- .../ChangeFileVisibilityCommandValidator.cs | 6 +- .../v1/DeleteFile/DeleteFileCommandHandler.cs | 19 +- .../FinalizeUploadCommandHandler.cs | 43 +++- .../GetFileDownloadUrlQueryHandler.cs | 19 +- .../GetFileMetadataQueryHandler.cs | 20 +- .../v1/ListMyFiles/ListMyFilesQueryHandler.cs | 5 +- .../RequestUploadUrlCommandHandler.cs | 52 ++++- .../RestoreFile/RestoreFileCommandHandler.cs | 7 +- .../Localization/FilesResources.cs | 4 + .../Localization/FilesResources.pt-BR.resx | 118 +++++++++++ .../Localization/FilesResources.resx | 118 +++++++++++ .../Files/Modules.Files/Modules.Files.csproj | 3 +- .../AddUsersToGroupCommandHandler.cs | 15 +- .../AddUsersToGroupCommandValidator.cs | 10 +- .../CreateGroup/CreateGroupCommandHandler.cs | 15 +- .../CreateGroupCommandValidator.cs | 10 +- .../DeleteGroup/DeleteGroupCommandHandler.cs | 14 +- .../DeleteGroupCommandValidator.cs | 6 +- .../GetGroupById/GetGroupByIdQueryHandler.cs | 8 +- .../GetGroupMembersQueryHandler.cs | 8 +- .../RemoveUserFromGroupCommandHandler.cs | 14 +- .../RemoveUserFromGroupCommandValidator.cs | 8 +- .../UpdateGroup/UpdateGroupCommandHandler.cs | 28 ++- .../UpdateGroupCommandValidator.cs | 12 +- .../EndImpersonationCommandHandler.cs | 13 +- .../GetImpersonationGrantsQueryHandler.cs | 5 +- .../GetImpersonationGrantsQueryValidator.cs | 6 +- .../RevokeImpersonationGrantCommandHandler.cs | 18 +- .../StartImpersonationCommandValidator.cs | 6 +- .../DeleteRole/DeleteRoleCommandValidator.cs | 6 +- .../Roles/GetRoles/GetRolesQueryValidator.cs | 8 +- .../Features/v1/Roles/RoleService.cs | 61 ++++-- .../UpsertRole/UpsertRoleCommandValidator.cs | 8 +- .../AdminRevokeAllSessionsCommandValidator.cs | 8 +- .../AdminRevokeSessionCommandValidator.cs | 10 +- .../GetTenantSessionsValidator.cs | 8 +- .../RevokeSessionCommandValidator.cs | 6 +- .../RefreshTokenCommandHandler.cs | 19 +- .../GenerateTokenCommandHandler.cs | 8 +- .../TokenGeneration/GenerateTokenEndpoint.cs | 7 +- .../Disable/DisableTwoFactorCommandHandler.cs | 14 +- .../Enroll/EnrollTwoFactorCommandHandler.cs | 14 +- .../VerifyEnrollTwoFactorCommandHandler.cs | 14 +- .../AdminConfirmEmailCommandValidator.cs | 6 +- .../AssignUserRolesCommandValidator.cs | 8 +- .../ChangePassword/ChangePasswordValidator.cs | 15 +- .../ConfirmEmailCommandValidator.cs | 10 +- .../DeleteUser/DeleteUserCommandValidator.cs | 6 +- .../GetUserGroupsQueryHandler.cs | 8 +- .../RegisterUserCommandValidator.cs | 32 +-- ...ResendConfirmationEmailCommandValidator.cs | 6 +- .../SearchUsers/SearchUsersQueryValidator.cs | 6 +- .../SetProfileImageCommandHandler.cs | 5 +- .../ToggleUserStatusCommandValidator.cs | 6 +- .../Services/CurrentUserService.cs | 13 +- .../Services/ImpersonationGrantService.cs | 7 +- .../Services/SessionService.cs | 24 ++- .../Services/UserPasswordService.cs | 30 ++- .../Services/UserRegistrationService.cs | 84 ++++++-- .../Services/UserRoleService.cs | 37 +++- .../Services/UserStatusService.cs | 52 ++++- .../AdjustTenantValidityCommandValidator.cs | 8 +- .../CreateTenantCommandValidator.cs | 14 +- .../v1/GetTenants/GetTenantsQueryValidator.cs | 6 +- .../RenewTenantCommandValidator.cs | 6 +- .../UpdateTenantThemeCommandValidator.cs | 50 ++--- .../Localization/MultitenancyResources.cs | 4 + .../MultitenancyResources.pt-BR.resx | 145 ++++++++++++++ .../Localization/MultitenancyResources.resx | 145 ++++++++++++++ .../Modules.Multitenancy.csproj | 3 +- .../MultitenancyModule.cs | 13 +- .../Provisioning/TenantProvisioningJob.cs | 8 +- .../Provisioning/TenantProvisioningService.cs | 36 +++- .../Services/TenantService.cs | 34 +++- .../Services/TenantThemeService.cs | 14 +- .../GetUnreadCountQueryHandler.cs | 8 +- .../ListNotificationsQueryHandler.cs | 8 +- .../MarkAllNotificationsReadCommandHandler.cs | 8 +- .../MarkNotificationReadCommandHandler.cs | 15 +- .../BillingEmailBodies.cs | 1 + .../Localization/NotificationsResources.cs | 4 + .../NotificationsResources.pt-BR.resx | 64 ++++++ .../Localization/NotificationsResources.resx | 64 ++++++ .../Modules.Notifications.csproj | 4 +- .../Tickets/Modules.Tickets/Domain/Ticket.cs | 33 +++- .../AddTicketCommentCommandHandler.cs | 14 +- .../AssignTicketCommandHandler.cs | 8 +- .../CloseTicket/CloseTicketCommandHandler.cs | 8 +- .../CreateTicketCommandHandler.cs | 7 +- .../DeleteTicketCommandHandler.cs | 8 +- .../GetTicketByIdQueryHandler.cs | 8 +- .../ListTicketCommentsQueryHandler.cs | 8 +- .../ReopenTicketCommandHandler.cs | 8 +- .../ResolveTicketCommandHandler.cs | 8 +- .../RestoreTicketCommandHandler.cs | 8 +- .../UpdateTicketCommandHandler.cs | 8 +- .../Localization/TicketsResources.cs | 4 + .../Localization/TicketsResources.pt-BR.resx | 85 ++++++++ .../Localization/TicketsResources.resx | 85 ++++++++ .../Modules.Tickets/Modules.Tickets.csproj | 3 +- ...eateWebhookSubscriptionCommandValidator.cs | 10 +- ...DeleteWebhookSubscriptionCommandHandler.cs | 8 +- .../TestWebhookSubscriptionCommandHandler.cs | 8 +- .../Localization/WebhooksResources.cs | 4 + .../Localization/WebhooksResources.pt-BR.resx | 73 +++++++ .../Localization/WebhooksResources.resx | 73 +++++++ .../Modules.Webhooks/Modules.Webhooks.csproj | 3 +- .../Architecture.Tests/CatalogParityTests.cs | 183 ++++++++++++++++++ .../Localization/AuditingResourcesTests.cs | 80 ++++++++ .../Localization/BillingResourcesTests.cs | 80 ++++++++ .../Localization/CatalogResourcesTests.cs | 104 ++++++++++ .../CatalogResourcesLocalizerFactory.cs | 19 ++ ...AdjustProductStockCommandValidatorTests.cs | 35 ++++ .../Localization/ChatResourcesTests.cs | 80 ++++++++ .../Support/ChatResourcesLocalizerFactory.cs | 19 ++ .../Localization/FilesResourcesTests.cs | 80 ++++++++ .../AuditingResourcesLocalizerFactory.cs | 19 ++ .../SharedResourcesLocalizerFactory.cs | 19 ++ .../Validators/DateRangeValidatorTests.cs | 36 ++-- .../Validators/PagedQueryValidatorTests.cs | 39 ++-- .../Localization/IdentityResourcesTests.cs | 80 ++++++++ .../CreateGroupCommandValidatorTests.cs | 3 +- .../DeleteUserCommandValidatorTests.cs | 3 +- .../UpdateGroupCommandValidatorTests.cs | 3 +- .../UpsertRoleCommandValidatorTests.cs | 3 +- .../MultitenancyResourcesTests.cs | 80 ++++++++ .../MultitenancyResourcesLocalizerFactory.cs | 19 ++ src/Tests/Tickets.Tests/GlobalUsings.cs | 2 + .../Localization/TicketsResourcesTests.cs | 103 ++++++++++ src/Tests/Tickets.Tests/Tickets.Tests.csproj | 25 +++ ...teWebhookSubscriptionSsrfValidatorTests.cs | 3 +- .../Localization/WebhooksResourcesTests.cs | 80 ++++++++ .../WebhooksResourcesLocalizerFactory.cs | 19 ++ .../Validators/WebhookValidatorTests.cs | 15 +- 229 files changed, 4713 insertions(+), 489 deletions(-) create mode 100644 src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs create mode 100644 src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx create mode 100644 src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx create mode 100644 src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs create mode 100644 src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx create mode 100644 src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx create mode 100644 src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs create mode 100644 src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx create mode 100644 src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx create mode 100644 src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs create mode 100644 src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx create mode 100644 src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx create mode 100644 src/Modules/Files/Modules.Files/Localization/FilesResources.cs create mode 100644 src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx create mode 100644 src/Modules/Files/Modules.Files/Localization/FilesResources.resx create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx create mode 100644 src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx create mode 100644 src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs create mode 100644 src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx create mode 100644 src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx create mode 100644 src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs create mode 100644 src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx create mode 100644 src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx create mode 100644 src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs create mode 100644 src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx create mode 100644 src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx create mode 100644 src/Tests/Architecture.Tests/CatalogParityTests.cs create mode 100644 src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs create mode 100644 src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs create mode 100644 src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs create mode 100644 src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs create mode 100644 src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs create mode 100644 src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs create mode 100644 src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs create mode 100644 src/Tests/Files.Tests/Localization/FilesResourcesTests.cs create mode 100644 src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs create mode 100644 src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs create mode 100644 src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs create mode 100644 src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs create mode 100644 src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs create mode 100644 src/Tests/Tickets.Tests/GlobalUsings.cs create mode 100644 src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs create mode 100644 src/Tests/Tickets.Tests/Tickets.Tests.csproj create mode 100644 src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs create mode 100644 src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs diff --git a/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs b/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs index e00af4e26a..93129df818 100644 --- a/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs +++ b/src/BuildingBlocks/Web/Validation/PagedQueryValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Persistence; +using Microsoft.Extensions.Localization; namespace FSH.Framework.Web.Validation; @@ -10,9 +12,9 @@ namespace FSH.Framework.Web.Validation; /// /// public class MyQueryValidator : AbstractValidator<MyQuery> /// { -/// public MyQueryValidator() +/// public MyQueryValidator(IStringLocalizer<SharedResources> localizer) /// { -/// Include(new PagedQueryValidator<MyQuery>()); +/// Include(new PagedQueryValidator<MyQuery>(localizer)); /// // Add additional rules... /// } /// } @@ -20,21 +22,21 @@ namespace FSH.Framework.Web.Validation; public sealed class PagedQueryValidator : AbstractValidator where T : IPagedQuery { - public PagedQueryValidator() + public PagedQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.PageNumber) .GreaterThan(0) .When(q => q.PageNumber.HasValue) - .WithMessage("Page number must be greater than 0."); + .WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(q => q.PageSize) .InclusiveBetween(1, 100) .When(q => q.PageSize.HasValue) - .WithMessage("Page size must be between 1 and 100."); + .WithMessage(_ => localizer["Validation.PageSizeRange"]); RuleFor(q => q.Sort) .MaximumLength(200) .When(q => !string.IsNullOrEmpty(q.Sort)) - .WithMessage("Sort expression must not exceed 200 characters."); + .WithMessage(_ => localizer["Validation.SortMaxLength"]); } } \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..7674befa8f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -143,5 +143,12 @@ AccessViolation). Transitive pinning is enabled, so this entry alone bumps it. Remove once the SignalR backplane package depends on a patched version itself. --> + + \ No newline at end of file diff --git a/src/FSH.Starter.slnx b/src/FSH.Starter.slnx index 998d538836..c51beb777a 100644 --- a/src/FSH.Starter.slnx +++ b/src/FSH.Starter.slnx @@ -80,6 +80,7 @@ + diff --git a/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs index 9dc0554d0f..d03ac8301b 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/Audit.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; using System.Diagnostics; @@ -56,13 +57,21 @@ public static Builder ForException(Exception ex, ExceptionArea area = ExceptionA eventType: AuditEventType.Exception, severity: severity ?? DefaultSeverity(ex), payload: new ExceptionEventPayload(area, - ex.GetType().FullName ?? "Exception", + RealExceptionType(ex).FullName ?? "Exception", ex.Message ?? string.Empty, StackTop(ex, maxFrames: 20), ToDict(ex.Data), routeOrLocation)); } + // Localization wrappers (LocalizedKeyNotFoundException, LocalizedUnauthorizedAccessException) exist + // only to translate the response body; for audit type identity and exceptionType filtering they must + // present as their BCL base so queries stay stable. CustomException-derived types keep their own identity. + private static Type RealExceptionType(Exception ex) => + ex is ILocalizableMessage and not CustomException && ex.GetType().BaseType is { } baseType + ? baseType + : ex.GetType(); + private static AuditSeverity DefaultSeverity(Exception ex) { if (ex is OperationCanceledException) diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs index ec9e845dee..122edc7123 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs @@ -1,5 +1,7 @@ +using FSH.Framework.Core.Exceptions; using FSH.Modules.Auditing.Contracts; using FSH.Modules.Auditing.Contracts.Dtos; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAuditById; using FSH.Modules.Auditing.Persistence; using Mediator; @@ -31,9 +33,15 @@ public async ValueTask Handle(GetAuditByIdQuery query, Cancellat if (record is null) { - // KeyNotFoundException maps to 404 globally. Kept (not framework NotFoundException) - // because audit exception-type fixtures and severity classification key off this type. - throw new KeyNotFoundException($"Audit record {query.Id} not found."); + // LocalizedKeyNotFoundException maps to 404 globally and still keys off KeyNotFoundException + // (its base) for audit exception-type fixtures and severity classification, while the body + // localizes via MessageKey under the request culture. + throw new LocalizedKeyNotFoundException($"Audit record {query.Id} not found.") + { + MessageKey = "Auditing.AuditRecordNotFound", + MessageArgs = [query.Id], + ResourceSource = typeof(AuditingResources), + }; } JsonElement payload; diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs index 90b954f77a..142cb1bc47 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Auditing.Contracts.Authorization; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Persistence; using FSH.Modules.Identity.Contracts.Services; using Mediator; @@ -13,7 +14,8 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; public sealed class GetAuditSummaryQueryHandler : IQueryHandler { - public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90); + public const int MaxWindowDays = 90; + public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays); public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(7); private readonly AuditDbContext _dbContext; @@ -104,7 +106,11 @@ requested is not null .ConfigureAwait(false); if (!allowed) { - throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant."); + throw new ForbiddenException("Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant.") + { + MessageKey = "Error.Auditing.CrossTenantSummaryForbidden", + ResourceSource = typeof(AuditingResources), + }; } return _dbContext.AuditRecords diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs index 76a39601a8..c1f0d82888 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryValidator.cs @@ -1,21 +1,26 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditSummary; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; public sealed class GetAuditSummaryQueryValidator : AbstractValidator { - public GetAuditSummaryQueryValidator() + public GetAuditSummaryQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditSummaryQueryHandler.MaxWindow) - .WithMessage($"Audit summary window cannot exceed {GetAuditSummaryQueryHandler.MaxWindow.TotalDays:0} days."); + // MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with + // string.Format under the current culture, and a double in a localized message is + // culture-sensitive by construction. An int cannot render a decimal separator. + .WithMessage(_ => localizer["Validation.SummaryWindowExceeded", GetAuditSummaryQueryHandler.MaxWindowDays]); } } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs index f14e46bad8..865d33e975 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs @@ -6,6 +6,7 @@ using FSH.Modules.Auditing.Contracts.Authorization; using FSH.Modules.Auditing.Contracts.Dtos; using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Auditing.Persistence; using FSH.Modules.Identity.Contracts.Services; using Mediator; @@ -21,7 +22,10 @@ public sealed class GetAuditsQueryHandler : IQueryHandler - public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(90); + public const int MaxWindowDays = 90; + + /// + public static readonly TimeSpan MaxWindow = TimeSpan.FromDays(MaxWindowDays); /// /// Default lookback when the caller does not supply a from/to. Keeps the @@ -157,7 +161,11 @@ requested is not null .ConfigureAwait(false); if (!allowed) { - throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant."); + throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant.") + { + MessageKey = "Error.Auditing.CrossTenantAccessForbidden", + ResourceSource = typeof(AuditingResources), + }; } return _dbContext.AuditRecords diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs index 3b3fb1a4ac..0591b7b433 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryValidator.cs @@ -1,18 +1,23 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Auditing.Contracts.v1.GetAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAudits; public sealed class GetAuditsQueryValidator : AbstractValidator { - public GetAuditsQueryValidator() + public GetAuditsQueryValidator( + IStringLocalizer localizer, + IStringLocalizer auditLocalizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => auditLocalizer["Validation.DateRangeOrder"]); // Reject oversized windows up-front (user sees a 400, not a silent clamp). The handler // still clamps as defence in depth (e.g. when only one endpoint is supplied). @@ -21,6 +26,9 @@ public GetAuditsQueryValidator() !q.FromUtc.HasValue || !q.ToUtc.HasValue || (q.ToUtc.Value - q.FromUtc.Value) <= GetAuditsQueryHandler.MaxWindow) - .WithMessage($"Audit query window cannot exceed {GetAuditsQueryHandler.MaxWindow.TotalDays:0} days."); + // MaxWindowDays, not MaxWindow.TotalDays: the localizer formats arguments with + // string.Format under the current culture, and a double in a localized message is + // culture-sensitive by construction. An int cannot render a decimal separator. + .WithMessage(_ => auditLocalizer["Validation.WindowExceeded", GetAuditsQueryHandler.MaxWindowDays]); } } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs index 3b00a8b5f9..d4039f0d25 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; public sealed class GetAuditsByCorrelationQueryValidator : AbstractValidator { - public GetAuditsByCorrelationQueryValidator() + public GetAuditsByCorrelationQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.CorrelationId) .NotEmpty(); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs index 8e8cdfbf13..74ffab9edc 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; public sealed class GetAuditsByTraceQueryValidator : AbstractValidator { - public GetAuditsByTraceQueryValidator() + public GetAuditsByTraceQueryValidator(IStringLocalizer localizer) { RuleFor(q => q.TraceId) .NotEmpty(); RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs index 0b08a67f82..9d5754ea16 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetExceptionAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; public sealed class GetExceptionAuditsQueryValidator : AbstractValidator { - public GetExceptionAuditsQueryValidator() + public GetExceptionAuditsQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs index 05d69a7334..7bc1a471ec 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; using FSH.Modules.Auditing.Contracts.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits; public sealed class GetSecurityAuditsQueryValidator : AbstractValidator { - public GetSecurityAuditsQueryValidator() + public GetSecurityAuditsQueryValidator(IStringLocalizer localizer) { RuleFor(q => q) .Must(q => !q.FromUtc.HasValue || !q.ToUtc.HasValue || q.FromUtc <= q.ToUtc) - .WithMessage("FromUtc must be less than or equal to ToUtc."); + .WithMessage(_ => localizer["Validation.DateRangeOrder"]); } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs new file mode 100644 index 0000000000..072a952413 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Auditing.Localization; + +/// Marker type binding IStringLocalizer<AuditingResources> to the Auditing resx catalog. +public sealed class AuditingResources; diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx new file mode 100644 index 0000000000..327d056243 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.pt-BR.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + O acesso a auditorias de outros inquilinos requer Permissions.AuditTrails.ViewCrossTenant. + + + O resumo de auditorias de outros inquilinos requer Permissions.AuditTrails.ViewCrossTenant. + + + FromUtc deve ser menor ou igual a ToUtc. + + + A janela de consulta de auditoria não pode exceder {0} dias. + + + A janela do resumo de auditoria não pode exceder {0} dias. + + + Registro de auditoria {0} não encontrado. + + diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx new file mode 100644 index 0000000000..ed95eb4dda --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditingResources.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant. + + + Cross-tenant audit summary requires Permissions.AuditTrails.ViewCrossTenant. + + + FromUtc must be less than or equal to ToUtc. + + + Audit query window cannot exceed {0} days. + + + Audit summary window cannot exceed {0} days. + + + Audit record {0} not found. + + diff --git a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj index 2c8b0a32de..ff7f4b1e61 100644 --- a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj +++ b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj @@ -3,7 +3,8 @@ FSH.Modules.Auditing FSH.Modules.Auditing - $(NoWarn);CA1031;CA1308;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1308;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs index c99a1b0c2a..9743e2c623 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Contracts.v1.Invoices; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; @@ -19,10 +20,17 @@ public async ValueTask Handle(GenerateInvoicesCommand command, Cancellation // Platform-wide invoice generation runs across EVERY tenant — it is a root-operator action. // A tenant admin (who also holds Billing.Manage) must not be able to trigger it. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; if (callerTenantId != MultitenancyConstants.Root.Id) { - throw new ForbiddenException("Only the root operator may generate invoices across tenants."); + throw new ForbiddenException("Only the root operator may generate invoices across tenants.") + { + MessageKey = "Billing.OnlyRootOperatorMayGenerateInvoices", + ResourceSource = typeof(BillingResources), + }; } return await billing.GenerateInvoicesForAllTenantsAsync(command.PeriodYear, command.PeriodMonth, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs index 62f4602d04..24bd651ab6 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Billing.Contracts.Dtos; using FSH.Modules.Billing.Contracts.v1.Invoices; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(GetInvoiceByIdQuery query, Cancellatio // BillingDbContext isn't tenant-filtered (raw DbContext for cross-tenant admin visibility): root // reads any invoice by id; a tenant caller is pinned to its own so it can't read another's. Mirrors GetSubscriptionQueryHandler. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +34,12 @@ public async ValueTask Handle(GetInvoiceByIdQuery query, Cancellatio i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found."); + ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [query.InvoiceId], + ResourceSource = typeof(BillingResources), + }; return invoice.ToDto(); } diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs index 4b6c88b5a3..303831089a 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(GetInvoicePdfQuery query, Cancel // BillingDbContext is not tenant-filtered: root may download ANY tenant's invoice PDF; a tenant // caller is pinned to its own, so a cross-tenant id resolves to 404 and never leaks a PDF. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var invoice = await dbContext.Invoices.AsNoTracking() @@ -30,7 +34,12 @@ public async ValueTask Handle(GetInvoicePdfQuery query, Cancel i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found."); + ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [query.InvoiceId], + ResourceSource = typeof(BillingResources), + }; var dto = invoice.ToDto(); var content = renderer.Render(dto); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs index ef07f2ebcd..a603fd8142 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs @@ -22,7 +22,10 @@ public async ValueTask> Handle(GetInvoicesQuery query, // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally // narrowed via query.TenantId); every other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs index 8485c5a373..f700bd5e29 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask> Handle(GetMyInvoicesQuery quer ArgumentNullException.ThrowIfNull(query); var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var q = dbContext.Invoices.AsNoTracking() .Include(i => i.LineItems) diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs index da86c3187d..f71eb9cb40 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Billing.Contracts.v1.Plans; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetPlanTermQuery query, Cancella #pragma warning restore CA1308 var plan = await dbContext.Plans.AsNoTracking() .FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Active plan with key '{query.PlanKey}' not found."); + ?? throw new NotFoundException($"Active plan with key '{query.PlanKey}' not found.") + { + MessageKey = "Billing.ActivePlanNotFound", + MessageArgs = [query.PlanKey], + ResourceSource = typeof(BillingResources), + }; return new PlanTermResponse( plan.Id, diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs index 233b749bd2..03b274b5a6 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Plans/UpdatePlan/UpdatePlanCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Billing.Contracts.v1.Plans; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -14,7 +15,12 @@ public async ValueTask Handle(UpdatePlanCommand command, CancellationToken ArgumentNullException.ThrowIfNull(command); var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Id == command.PlanId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {command.PlanId} not found."); + ?? throw new NotFoundException($"Plan {command.PlanId} not found.") + { + MessageKey = "Billing.PlanNotFound", + MessageArgs = [command.PlanId], + ResourceSource = typeof(BillingResources), + }; plan.Update(command.Name, command.MonthlyBasePrice, command.OverageRates, command.Interval, command.AnnualPrice); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs index 2348de90ff..0f1e368d59 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Billing.Contracts.v1.Subscriptions; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,10 @@ public async ValueTask Handle(AssignSubscriptionCommand command, Cancellat // Only root may target an arbitrary tenant; a tenant caller is pinned to its own, so it can't // (re)assign or cancel another tenant's subscription via a foreign tenant id in the body. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var targetTenantId = isRoot ? command.TenantId : callerTenantId; @@ -29,7 +33,12 @@ public async ValueTask Handle(AssignSubscriptionCommand command, Cancellat var key = command.PlanKey.ToLowerInvariant(); #pragma warning restore CA1308 var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Active plan with key '{command.PlanKey}' not found."); + ?? throw new NotFoundException($"Active plan with key '{command.PlanKey}' not found.") + { + MessageKey = "Billing.ActivePlanNotFound", + MessageArgs = [command.PlanKey], + ResourceSource = typeof(BillingResources), + }; var now = DateTime.UtcNow; var current = await dbContext.Subscriptions diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs index 5e0f8c8538..6c55f9d7df 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/GetSubscription/GetSubscriptionQueryHandler.cs @@ -19,7 +19,10 @@ public sealed class GetSubscriptionQueryHandler( ArgumentNullException.ThrowIfNull(query); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; // BillingDbContext is not tenant-filtered, so a tenant caller is pinned to its OWN // subscription and only root may pass an arbitrary tenant id (else cross-tenant reads). diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs index 930c39de9c..996a9cb910 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs @@ -21,7 +21,10 @@ public async ValueTask> Handle( // Only the root operator may capture usage for an arbitrary tenant; a tenant caller is pinned // to its own tenant so it can't fabricate another tenant's usage/overage snapshots. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var targetTenantId = isRoot ? command.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs index a49afdae72..395052b770 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs @@ -21,7 +21,10 @@ public async ValueTask> Handle(GetUsageSnapshots // UsageSnapshots is not tenant-filtered. Only the root operator may read across tenants // (optionally narrowed via query.TenantId); any other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs index d1b92dca6e..e64e0c8663 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/ApproveTopupRequest/ApproveTopupRequestCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Billing.Contracts.v1.Wallets; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using FSH.Modules.Billing.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,29 @@ public async ValueTask Handle(ApproveTopupRequestCommand command, Cancella ArgumentNullException.ThrowIfNull(command); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var request = await db.TopupRequests .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + ?? throw new NotFoundException($"Top-up request {command.Id} not found.") + { + MessageKey = "Billing.TopupRequestNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(BillingResources), + }; if (!isRoot && request.TenantId != callerTenantId) { - throw new UnauthorizedException("You can only approve top-up requests for your own tenant."); + throw new UnauthorizedException("You can only approve top-up requests for your own tenant.") + { + MessageKey = "Billing.CannotApproveTopupForOtherTenant", + ResourceSource = typeof(BillingResources), + }; } // For root, operate on the request's own tenant; for non-root, callerTenantId equals request.TenantId. diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs index b5621e5b6f..2d74244ce8 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/CreateTopupRequest/CreateTopupRequestCommandHandler.cs @@ -21,7 +21,10 @@ public async ValueTask Handle(CreateTopupRequestCommand command, Cancellat // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var requestedBy = currentUser.IsAuthenticated() ? currentUser.GetUserId().ToString() : null; var request = TopupRequest.Create(tenantId, command.Amount, "USD", command.Note, requestedBy); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs index d87440550b..e1ee030975 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyTopupRequests/GetMyTopupRequestsQueryHandler.cs @@ -22,7 +22,10 @@ public async ValueTask> Handle(GetMyTopupRequests // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var q = dbContext.TopupRequests.AsNoTracking() .Where(r => r.TenantId == tenantId); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs index 37512d4582..d77dd0f535 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetMyWallet/GetMyWalletQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask Handle(GetMyWalletQuery query, CancellationTok // BillingDbContext is not tenant-filtered; resolve caller's own tenant and scope strictly to it. var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var wallet = await billingService.GetOrCreateWalletAsync(tenantId, "USD", cancellationToken).ConfigureAwait(false); return wallet.ToDto(); diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs index 51adf97f43..8da6936a97 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/GetTopupRequests/GetTopupRequestsQueryHandler.cs @@ -23,7 +23,10 @@ public async ValueTask> Handle(GetTopupRequestsQu // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally // narrowed via query.TenantId); every other caller is forced to its own tenant. var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var tenantFilter = isRoot ? query.TenantId : callerTenantId; diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs index 34ff4ea4c4..f7f444f500 100644 --- a/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs +++ b/src/Modules/Billing/Modules.Billing/Features/v1/Wallets/RejectTopupRequest/RejectTopupRequestCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Billing.Contracts; using FSH.Modules.Billing.Contracts.v1.Wallets; using FSH.Modules.Billing.Data; +using FSH.Modules.Billing.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,29 @@ public async ValueTask Handle(RejectTopupRequestCommand command, Cancellat ArgumentNullException.ThrowIfNull(command); var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; var request = await db.TopupRequests .FirstOrDefaultAsync(r => r.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {command.Id} not found."); + ?? throw new NotFoundException($"Top-up request {command.Id} not found.") + { + MessageKey = "Billing.TopupRequestNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(BillingResources), + }; if (!isRoot && request.TenantId != callerTenantId) { - throw new UnauthorizedException("You can only reject top-up requests for your own tenant."); + throw new UnauthorizedException("You can only reject top-up requests for your own tenant.") + { + MessageKey = "Billing.CannotRejectTopupForOtherTenant", + ResourceSource = typeof(BillingResources), + }; } if (request.Status != TopupRequestStatus.Pending) @@ -38,7 +51,12 @@ public async ValueTask Handle(RejectTopupRequestCommand command, Cancellat throw new CustomException( $"Top-up request {command.Id} cannot be rejected because it is {request.Status} (only Pending requests can be rejected).", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Billing.TopupRequestCannotBeRejected", + MessageArgs = [command.Id, request.Status], + ResourceSource = typeof(BillingResources), + }; } request.Reject(command.Reason); diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs new file mode 100644 index 0000000000..1ab5911ad5 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Billing.Localization; + +/// Marker type binding IStringLocalizer<BillingResources> to the Billing resx catalog. +public sealed class BillingResources; diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx new file mode 100644 index 0000000000..1e7c1a962f --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Fatura {0} não encontrada. + + + Plano {0} não encontrado. + + + Plano {0} não encontrado para o tenant {1}. + + + Plano ativo com a chave '{0}' não encontrado. + + + Solicitação de recarga {0} não encontrada. + + + Solicitação de recarga {0} não encontrada ou não está pendente. + + + Apenas o operador raiz pode gerar faturas entre tenants. + + + Você só pode rejeitar solicitações de recarga do seu próprio tenant. + + + Você só pode aprovar solicitações de recarga do seu próprio tenant. + + + Não é possível rejeitar a solicitação de recarga {0} porque ela está {1} (somente solicitações Pendentes podem ser rejeitadas). + + diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx new file mode 100644 index 0000000000..7b6363b307 --- /dev/null +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Invoice {0} not found. + + + Plan {0} not found. + + + Plan {0} not found for tenant {1}. + + + Active plan with key '{0}' not found. + + + Top-up request {0} not found. + + + Top-up request {0} not found or not pending. + + + Only the root operator may generate invoices across tenants. + + + You can only reject top-up requests for your own tenant. + + + You can only approve top-up requests for your own tenant. + + + Top-up request {0} cannot be rejected because it is {1} (only Pending requests can be rejected). + + diff --git a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj index 75f90198a1..429ae3d723 100644 --- a/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj +++ b/src/Modules/Billing/Modules.Billing/Modules.Billing.csproj @@ -3,7 +3,7 @@ FSH.Modules.Billing FSH.Modules.Billing - $(NoWarn);CA1031;CA1711;CA1812;CA1859;S3267 + $(NoWarn);CA1031;CA1711;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs index 5b684ab0b4..0b30ade16c 100644 --- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs +++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Billing.Contracts.Events; using FSH.Modules.Billing.Data; using FSH.Modules.Billing.Domain; +using FSH.Modules.Billing.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -75,7 +76,12 @@ public BillingService( } var plan = await _db.Plans.FirstOrDefaultAsync(p => p.Id == subscription.PlanId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {subscription.PlanId} not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Plan {subscription.PlanId} not found for tenant {tenantId}.") + { + MessageKey = "Billing.PlanNotFoundForTenant", + MessageArgs = [subscription.PlanId, tenantId], + ResourceSource = typeof(BillingResources), + }; var snapshots = await _usageReporter.CaptureForPeriodAsync(tenantId, periodYear, periodMonth, cancellationToken).ConfigureAwait(false); @@ -188,7 +194,12 @@ public async Task CreateTopupInvoiceAsync(string tenantId, Guid topupRe var request = await _db.TopupRequests .FirstOrDefaultAsync(r => r.Id == topupRequestId && r.TenantId == tenantId && r.Status == TopupRequestStatus.Pending, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Top-up request {topupRequestId} not found or not pending."); + ?? throw new NotFoundException($"Top-up request {topupRequestId} not found or not pending.") + { + MessageKey = "Billing.TopupRequestNotFoundOrNotPending", + MessageArgs = [topupRequestId], + ResourceSource = typeof(BillingResources), + }; var now = _timeProvider.GetUtcNow().UtcDateTime; var invoiceNumber = BuildTopupInvoiceNumber(tenantId, now, topupRequestId); @@ -289,13 +300,21 @@ public async Task VoidInvoiceAsync(Guid invoiceId, string? reason, CancellationT private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken cancellationToken) { var callerTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id - ?? throw new UnauthorizedException("Tenant context is required."); + ?? throw new UnauthorizedException("Tenant context is required.") + { + MessageKey = "Error.TenantContextRequired", + }; var isRoot = callerTenantId == MultitenancyConstants.Root.Id; return await _db.Invoices .FirstOrDefaultAsync(i => i.Id == invoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Invoice {invoiceId} not found."); + ?? throw new NotFoundException($"Invoice {invoiceId} not found.") + { + MessageKey = "Billing.InvoiceNotFound", + MessageArgs = [invoiceId], + ResourceSource = typeof(BillingResources), + }; } public async Task CreateSubscriptionInvoiceAsync( @@ -308,7 +327,12 @@ private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken c ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); var plan = await _db.Plans.FirstOrDefaultAsync(p => p.Id == planId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}.") + { + MessageKey = "Billing.PlanNotFoundForTenant", + MessageArgs = [planId, tenantId], + ResourceSource = typeof(BillingResources), + }; var termPrice = plan.TermPrice; if (termPrice.Amount <= 0m) diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs index 95130c2bcd..4d89c4bbcf 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/CreateBrand/CreateBrandCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask Handle(CreateBrandCommand command, CancellationToke throw new CustomException( $"A brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.BrandNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Brands.Add(brand); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs index f32da24e77..34965b7575 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/DeleteBrand/DeleteBrandCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteBrandCommand command, CancellationToke var brand = await dbContext.Brands .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; dbContext.Brands.Remove(brand); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs index 0963c6a11d..2c62cdccc6 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/GetBrandById/GetBrandByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetBrandByIdQuery query, CancellationTok .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == query.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {query.BrandId} not found."); + ?? throw new NotFoundException($"Brand {query.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [query.BrandId], + ResourceSource = typeof(CatalogResources), + }; return new BrandDto( brand.Id, diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs index 061e97df26..a71cae0134 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/RestoreBrand/RestoreBrandCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -20,7 +21,12 @@ public async ValueTask Handle(RestoreBrandCommand command, CancellationTok .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; brand.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs index 0e5ba4c14a..45416dde79 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Brands/UpdateBrand/UpdateBrandCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Brands; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateBrandCommand command, CancellationToke var brand = await dbContext.Brands .FirstOrDefaultAsync(b => b.Id == command.BrandId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Brand {command.BrandId} not found."); + ?? throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; brand.Update(command.Name, command.Description, command.LogoUrl); @@ -29,7 +35,12 @@ public async ValueTask Handle(UpdateBrandCommand command, CancellationToke throw new CustomException( $"Another brand with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherBrandNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs index adc5edfb6e..451d15618d 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/CreateCategory/CreateCategoryCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,12 @@ public async ValueTask Handle(CreateCategoryCommand command, CancellationT .ConfigureAwait(false); if (!parentExists) { - throw new NotFoundException($"Parent category {parentId} not found."); + throw new NotFoundException($"Parent category {parentId} not found.") + { + MessageKey = "Catalog.ParentCategoryNotFound", + MessageArgs = [parentId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -36,7 +42,12 @@ public async ValueTask Handle(CreateCategoryCommand command, CancellationT throw new CustomException( $"A category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.CategoryNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Categories.Add(category); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs index a596dce866..60d97e8e16 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/DeleteCategory/DeleteCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(DeleteCategoryCommand command, CancellationT var category = await dbContext.Categories .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; bool hasChildren = await dbContext.Categories .AnyAsync(c => c.ParentCategoryId == category.Id, cancellationToken) @@ -27,7 +33,11 @@ public async ValueTask Handle(DeleteCategoryCommand command, CancellationT throw new CustomException( "Cannot delete a category that has child categories. Move or remove the children first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.CategoryHasChildren", + ResourceSource = typeof(CatalogResources), + }; } dbContext.Categories.Remove(category); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs index 793fb49bcd..ce9552acac 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/GetCategoryById/GetCategoryByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetCategoryByIdQuery query, Cancellat .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == query.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {query.CategoryId} not found."); + ?? throw new NotFoundException($"Category {query.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [query.CategoryId], + ResourceSource = typeof(CatalogResources), + }; return new CategoryDto(c.Id, c.Name, c.Slug, c.Description, c.ParentCategoryId, c.CreatedAtUtc, c.UpdatedAtUtc, c.DeletedOnUtc, c.DeletedBy); } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs index 3b29c8540d..ad36397147 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/RestoreCategory/RestoreCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreCategoryCommand command, Cancellation .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; category.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs index 9fa8a5053d..61242c7e5f 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Categories/UpdateCategory/UpdateCategoryCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Categories; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT var category = await dbContext.Categories .FirstOrDefaultAsync(c => c.Id == command.CategoryId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Category {command.CategoryId} not found."); + ?? throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; if (command.ParentCategoryId is { } parentId) { @@ -26,7 +32,11 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "A category cannot be its own parent.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Catalog.CategoryCannotBeOwnParent", + ResourceSource = typeof(CatalogResources), + }; } // Walk parent chain to detect cycles (parent → ancestor of self) @@ -39,7 +49,11 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( "Setting this parent would create a cycle.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Catalog.CategoryParentCycle", + ResourceSource = typeof(CatalogResources), + }; } cursor = await dbContext.Categories .Where(c => c.Id == cur) @@ -59,7 +73,12 @@ public async ValueTask Handle(UpdateCategoryCommand command, CancellationT throw new CustomException( $"Another category with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherCategoryNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs index 1bbb943009..dc2248f7f3 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AddProductImage/AddProductImageCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Products.AddProductImage; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(AddProductImageCommand command, C var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; var image = product.AddImage(command.FileAssetId, command.Url); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs index a82c413ce0..6156c8f6e5 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(AdjustProductStockCommand command, Cancellati var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; try { @@ -25,7 +31,12 @@ public async ValueTask Handle(AdjustProductStockCommand command, Cancellati } catch (InvalidOperationException ex) { - throw new CustomException(ex.Message, (IEnumerable?)null, HttpStatusCode.Conflict); + throw new CustomException(ex.Message, (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Catalog.StockAdjustmentNegative", + MessageArgs = [command.Delta, product.Stock], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs index 6f2b5b3d07..1070bc2833 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/AdjustProductStock/AdjustProductStockCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; using FSH.Modules.Catalog.Contracts.v1.Products; +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Catalog.Features.v1.Products.AdjustProductStock; public sealed class AdjustProductStockCommandValidator : AbstractValidator { - public AdjustProductStockCommandValidator() + public AdjustProductStockCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.ProductId).NotEmpty(); - RuleFor(x => x.Delta).NotEqual(0).WithMessage("Delta must be non-zero."); + RuleFor(x => x.Delta).NotEqual(0).WithMessage(_ => localizer["Validation.DeltaNonZero"]); } } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs index 8a101e9155..4abe3b18bc 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ChangeProductPrice/ChangeProductPriceCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(ChangeProductPriceCommand command, Cancellat var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.ChangePrice(new Money(command.Amount, command.Currency)); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs index d3e75608a1..4a6701c851 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/CreateProduct/CreateProductCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; using FSH.Modules.Catalog.Domain; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo .ConfigureAwait(false); if (!brandExists) { - throw new NotFoundException($"Brand {command.BrandId} not found."); + throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; } bool categoryExists = await dbContext.Categories @@ -29,7 +35,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo .ConfigureAwait(false); if (!categoryExists) { - throw new NotFoundException($"Category {command.CategoryId} not found."); + throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; } var product = Product.Create( @@ -49,7 +60,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with SKU '{product.Sku}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.ProductSkuAlreadyExists", + MessageArgs = [product.Sku], + ResourceSource = typeof(CatalogResources), + }; } bool slugTaken = await dbContext.Products @@ -60,7 +76,12 @@ public async ValueTask Handle(CreateProductCommand command, CancellationTo throw new CustomException( $"A product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.ProductNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } dbContext.Products.Add(product); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs index 01be205865..43f04c46f8 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -19,7 +20,12 @@ public async ValueTask Handle(DeleteProductCommand command, CancellationTo .IgnoreAutoIncludes() .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; dbContext.Products.Remove(product); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs index 7e36cc4cee..243b7362d1 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/GetProductById/GetProductByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Catalog.Contracts.Dtos; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(GetProductByIdQuery query, Cancellatio .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == query.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {query.ProductId} not found."); + ?? throw new NotFoundException($"Product {query.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [query.ProductId], + ResourceSource = typeof(CatalogResources), + }; return product.ToDto(); } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs index db0db8006c..fbc0fc9865 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RemoveProductImage/RemoveProductImageCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.RemoveProductImage; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,12 +17,22 @@ public async ValueTask Handle(RemoveProductImageCommand command, Cancellat var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; // Domain throws InvalidOperationException for unknown imageId; translate to 404. if (!product.Images.Any(i => i.Id == command.ImageId)) { - throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}."); + throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}.") + { + MessageKey = "Catalog.ProductImageNotFound", + MessageArgs = [command.ImageId, command.ProductId], + ResourceSource = typeof(CatalogResources), + }; } product.RemoveImage(command.ImageId); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs index 7441d60e8e..5ec439d6f1 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/ReorderProductImages/ReorderProductImagesCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.ReorderProductImages; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ReorderProductImagesCommand command, Cancell var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.ReorderImages(command.OrderedImageIds); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs index 235baee8d3..e6424dab84 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/RestoreProduct/RestoreProductCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Persistence; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreProductCommand command, CancellationT .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; product.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs index 7b9232d82a..1d1522f078 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/SetProductThumbnail/SetProductThumbnailCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products.SetProductThumbnail; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,13 +17,23 @@ public async ValueTask Handle(SetProductThumbnailCommand command, Cancella var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; // Domain throws InvalidOperationException for unknown imageId; translate to a // framework-aware 404 so the API surfaces NotFound rather than a 500. if (!product.Images.Any(i => i.Id == command.ImageId)) { - throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}."); + throw new NotFoundException($"Image {command.ImageId} not found on product {command.ProductId}.") + { + MessageKey = "Catalog.ProductImageNotFound", + MessageArgs = [command.ImageId, command.ProductId], + ResourceSource = typeof(CatalogResources), + }; } product.SetThumbnail(command.ImageId); diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs index 0d99b89eab..ff84ca4611 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/UpdateProduct/UpdateProductCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Catalog.Contracts.v1.Products; using FSH.Modules.Catalog.Data; +using FSH.Modules.Catalog.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Product {command.ProductId} not found."); + ?? throw new NotFoundException($"Product {command.ProductId} not found.") + { + MessageKey = "Catalog.ProductNotFound", + MessageArgs = [command.ProductId], + ResourceSource = typeof(CatalogResources), + }; if (product.BrandId != command.BrandId) { @@ -26,7 +32,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo .ConfigureAwait(false); if (!brandExists) { - throw new NotFoundException($"Brand {command.BrandId} not found."); + throw new NotFoundException($"Brand {command.BrandId} not found.") + { + MessageKey = "Catalog.BrandNotFound", + MessageArgs = [command.BrandId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -37,7 +48,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo .ConfigureAwait(false); if (!categoryExists) { - throw new NotFoundException($"Category {command.CategoryId} not found."); + throw new NotFoundException($"Category {command.CategoryId} not found.") + { + MessageKey = "Catalog.CategoryNotFound", + MessageArgs = [command.CategoryId], + ResourceSource = typeof(CatalogResources), + }; } } @@ -56,7 +72,12 @@ public async ValueTask Handle(UpdateProductCommand command, CancellationTo throw new CustomException( $"Another product with name '{command.Name}' already exists.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Catalog.AnotherProductNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(CatalogResources), + }; } await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs new file mode 100644 index 0000000000..6258320c1c --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Catalog.Localization; + +/// Marker type binding IStringLocalizer<CatalogResources> to the Catalog resx catalog. +public sealed class CatalogResources; diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx new file mode 100644 index 0000000000..5f08aaceeb --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.pt-BR.resx @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Produto {0} não encontrado. + + + Marca {0} não encontrada. + + + Categoria {0} não encontrada. + + + Categoria pai {0} não encontrada. + + + Imagem {0} não encontrada no produto {1}. + + + Já existe um produto com o SKU '{0}'. + + + Já existe um produto com o nome '{0}'. + + + Já existe outro produto com o nome '{0}'. + + + Já existe uma marca com o nome '{0}'. + + + Já existe outra marca com o nome '{0}'. + + + Já existe uma categoria com o nome '{0}'. + + + Já existe outra categoria com o nome '{0}'. + + + Uma categoria não pode ser pai de si mesma. + + + Definir este pai criaria um ciclo. + + + Não é possível excluir uma categoria que possui categorias filhas. Mova ou remova as filhas primeiro. + + + O delta deve ser diferente de zero. + + + O ajuste de estoque de {0} resultaria em estoque negativo (atual: {1}). + + diff --git a/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx new file mode 100644 index 0000000000..7594ae3200 --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Localization/CatalogResources.resx @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Product {0} not found. + + + Brand {0} not found. + + + Category {0} not found. + + + Parent category {0} not found. + + + Image {0} not found on product {1}. + + + A product with SKU '{0}' already exists. + + + A product with name '{0}' already exists. + + + Another product with name '{0}' already exists. + + + A brand with name '{0}' already exists. + + + Another brand with name '{0}' already exists. + + + A category with name '{0}' already exists. + + + Another category with name '{0}' already exists. + + + A category cannot be its own parent. + + + Setting this parent would create a cycle. + + + Cannot delete a category that has child categories. Move or remove the children first. + + + Delta must be non-zero. + + + Stock adjustment of {0} would result in negative stock (current: {1}). + + diff --git a/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj b/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj index f8f033ed05..72c4e33471 100644 --- a/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj +++ b/src/Modules/Catalog/Modules.Catalog/Modules.Catalog.csproj @@ -3,7 +3,8 @@ FSH.Modules.Catalog FSH.Modules.Catalog - $(NoWarn);CA1031;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs index c80fe1ccd4..a3011a9bc8 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,18 +22,26 @@ public async ValueTask Handle(AddChannelMembersCommand cmd, CancellationTo { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Members can invite to public channels they belong to; private channels require Admin. var caller = channel.RequireMember(currentUserId); if (channel.IsPrivate && caller.Role != ChannelMemberRole.Admin) { - throw new ForbiddenException("Only channel admins can add members to private channels."); + throw new ForbiddenException("Only channel admins can add members to private channels.") + { + MessageKey = "Chat.OnlyAdminsCanAddMembersToPrivateChannel", + ResourceSource = typeof(ChatResources), + }; } var newlyAdded = new List(); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs index a1654617a3..fd19956d49 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ArchiveChannel/ArchiveChannelCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,11 +18,15 @@ public async ValueTask Handle(ArchiveChannelCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireAdmin(userId.ToString()); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs index 308dee6655..6de899d1a4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/CreateChannel/CreateChannelCommandHandler.cs @@ -18,7 +18,7 @@ public async ValueTask Handle(CreateChannelCommand cmd, CancellationToken var userId = currentUser.GetUserId().ToString(); if (userId == Guid.Empty.ToString()) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; } var channel = ChatChannel.CreateChannel(cmd.Name, cmd.Description, cmd.IsPrivate, userId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs index e29d15045c..bb9846dff4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/DiscoverChannels/DiscoverChannelsQueryHandler.cs @@ -20,7 +20,7 @@ public async ValueTask> Handle(DiscoverChannelsQu { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs index 0e52a8c295..8f3c1e7251 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/FindOrCreateDm/FindOrCreateDmCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,13 +22,17 @@ public async ValueTask Handle(FindOrCreateDmCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var otherIds = cmd.UserIds.Distinct(StringComparer.Ordinal).ToList(); if (otherIds.Any(id => string.Equals(id, currentUserId, StringComparison.Ordinal))) { - throw new CustomException("Cannot DM yourself.", (IEnumerable?)null, System.Net.HttpStatusCode.BadRequest); + throw new CustomException("Cannot DM yourself.", (IEnumerable?)null, System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Chat.CannotDmYourself", + ResourceSource = typeof(ChatResources), + }; } if (otherIds.Count == 1) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs index d766d06112..0a36112071 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/GetChannelById/GetChannelByIdQueryHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,13 +19,17 @@ public async ValueTask Handle(GetChannelByIdQuery q, CancellationTok { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.AsNoTracking() .FirstOrDefaultAsync(c => c.Id == q.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Private channels & DMs: must be a member. Public channels: anyone with View can see them. if (channel.IsPrivate) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs index eca1da32f7..f448a41843 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/ListMyChannels/ListMyChannelsQueryHandler.cs @@ -19,7 +19,7 @@ public async ValueTask> Handle(ListMyChannelsQuer { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs index 8aa1ea9b44..d65d6cb06e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/MarkChannelRead/MarkChannelReadCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,19 +21,30 @@ public async ValueTask Handle(MarkChannelReadCommand cmd, CancellationToke { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); // Verify the marker message actually exists in this channel. var exists = await db.Messages .AnyAsync(m => m.Id == cmd.MessageId && m.ChannelId == cmd.ChannelId, cancellationToken) .ConfigureAwait(false); - if (!exists) throw new NotFoundException("Message not found in this channel."); + if (!exists) + { + throw new NotFoundException("Message not found in this channel.") + { + MessageKey = "Chat.MessageNotFoundInChannel", + ResourceSource = typeof(ChatResources), + }; + } channel.MarkRead(currentUserId, cmd.MessageId); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs index 0e0f06e757..a229200610 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RemoveChannelMember/RemoveChannelMemberCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -21,12 +22,16 @@ public async ValueTask Handle(RemoveChannelMemberCommand cmd, Cancellation { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; // Self-leave is always allowed for the current user. Removing someone else requires Admin. var isSelfLeave = string.Equals(cmd.UserId, currentUserId, StringComparison.Ordinal); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs index c878d4e8ce..3966e232d1 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/RestoreChannel/RestoreChannelCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,7 +18,11 @@ public async ValueTask Handle(RestoreChannelCommand cmd, CancellationToken var channel = await db.Channels.IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; if (!channel.IsDeleted) return Unit.Value; // idempotent channel.Restore(); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs index 980fdea854..d449df5f9e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Channels/UpdateChannel/UpdateChannelCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -17,11 +18,15 @@ public async ValueTask Handle(UpdateChannelCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireAdmin(userId.ToString()); channel.Rename(cmd.Name, cmd.Description); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs index b217e9b3c9..f38e7d6c6f 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChannelAuthorization.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Chat.Contracts.v1.DTOs; using FSH.Modules.Chat.Domain; +using FSH.Modules.Chat.Localization; namespace FSH.Modules.Chat.Features.v1.Internal; @@ -14,7 +15,11 @@ public static ChannelMember RequireMember(this ChatChannel channel, string userI { var member = channel.Members.FirstOrDefault(m => string.Equals(m.UserId, userId, StringComparison.Ordinal)); // Use NotFoundException (404) instead of Forbidden so non-members can't probe channel existence. - return member ?? throw new NotFoundException("Channel not found."); + return member ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; } public static ChannelMember RequireAdmin(this ChatChannel channel, string userId) @@ -22,7 +27,11 @@ public static ChannelMember RequireAdmin(this ChatChannel channel, string userId var member = channel.RequireMember(userId); if (member.Role != ChannelMemberRole.Admin) { - throw new ForbiddenException("Channel admin role required."); + throw new ForbiddenException("Channel admin role required.") + { + MessageKey = "Chat.ChannelAdminRoleRequired", + ResourceSource = typeof(ChatResources), + }; } return member; } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs index 0127455da7..e29a70ec77 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/DeleteMessage/DeleteMessageCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using FSH.Modules.Identity.Contracts.Services; using Mediator; using Microsoft.AspNetCore.SignalR; @@ -23,16 +24,24 @@ public async ValueTask Handle(DeleteMessageCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); bool isModerator = await permissions diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs index 92ce8fad72..1c4065b1dd 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/EditMessage/EditMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,25 @@ public async ValueTask Handle(EditMessageCommand cmd, CancellationToken ca { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; // Verify membership through the parent channel (don't leak existence to non-members). var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Edit(cmd.Body, currentUserId); // domain enforces author-only diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs index 20f3213b5c..46253936d5 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/GetPinnedMessages/GetPinnedMessagesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,12 +23,16 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); var rows = await db.Messages diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs index e845953d78..088563bc68 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListChannelMessages/ListChannelMessagesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,12 +23,16 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); // Top-level only (no thread replies). Guid v7 monotonic → Id desc = time desc. diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs index 538e110717..814cc4c7d0 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/ListMessageReplies/ListMessageRepliesQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Chat.Contracts.v1.Queries; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,7 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); // Load the parent so we can authorize the caller through the channel. @@ -31,11 +32,19 @@ public async ValueTask> Handle( .Select(m => new { m.Id, m.ChannelId }) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == parent.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); IQueryable q = db.Messages diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs index 47a1e07442..3ea9c8f473 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/PinMessage/PinMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(PinMessageCommand cmd, CancellationToken can { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Pin(currentUserId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs index b2c2511dc3..40d375c123 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandHandler.cs @@ -10,6 +10,7 @@ using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Domain; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using FSH.Modules.Chat.Services; using Mediator; using Microsoft.AspNetCore.SignalR; @@ -29,12 +30,16 @@ public async ValueTask Handle(SendMessageCommand cmd, CancellationTo { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Channel not found."); + ?? throw new NotFoundException("Channel not found.") + { + MessageKey = "Chat.ChannelNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); @@ -43,15 +48,27 @@ public async ValueTask Handle(SendMessageCommand cmd, CancellationTo { parent = await db.Messages.FirstOrDefaultAsync(m => m.Id == parentId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Parent message not found."); + ?? throw new NotFoundException("Parent message not found.") + { + MessageKey = "Chat.ParentMessageNotFound", + ResourceSource = typeof(ChatResources), + }; if (parent.ChannelId != channel.Id) { - throw new CustomException("Parent message belongs to a different channel.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException("Parent message belongs to a different channel.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Chat.ParentMessageDifferentChannel", + ResourceSource = typeof(ChatResources), + }; } if (parent.ParentMessageId.HasValue) { // 1-level deep only per spec. - throw new CustomException("Cannot reply to a reply — threads are single-level only.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException("Cannot reply to a reply — threads are single-level only.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Chat.CannotReplyToReply", + ResourceSource = typeof(ChatResources), + }; } } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs index f385568961..0252dfe299 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/SendMessage/SendMessageCommandValidator.cs @@ -1,11 +1,13 @@ using FluentValidation; using FSH.Modules.Chat.Contracts.v1.Commands; +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Chat.Features.v1.Messages.SendMessage; public sealed class SendMessageCommandValidator : AbstractValidator { - public SendMessageCommandValidator() + public SendMessageCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.ChannelId).NotEmpty(); // Body is optional when an attachment is present (Slack/Teams parity — "here's the file" with @@ -13,7 +15,7 @@ public SendMessageCommandValidator() RuleFor(x => x.Body) .NotEmpty() .When(x => x.Attachments is null || x.Attachments.Count == 0) - .WithMessage("Either a body or an attachment is required."); + .WithMessage(_ => localizer["Validation.BodyOrAttachmentRequired"]); RuleFor(x => x.Body) .MaximumLength(32_768) .When(x => !string.IsNullOrEmpty(x.Body)); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs index 65beefe000..5fc34f0b35 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Messages/UnpinMessage/UnpinMessageCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(UnpinMessageCommand cmd, CancellationToken c { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); message.Unpin(currentUserId); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs index bd22475b8a..b85d987840 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/AddReaction/AddReactionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,17 +21,25 @@ public async ValueTask Handle(AddReactionCommand cmd, CancellationToken ca { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; // Authorize through the parent channel — don't leak existence to non-members. var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); var added = message.AddReaction(currentUserId, cmd.Emoji); diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs index 996127ed01..695085bc4e 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Reactions/RemoveReaction/RemoveReactionCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Chat.Contracts.v1.Commands; using FSH.Modules.Chat.Data; using FSH.Modules.Chat.Features.v1.Internal; +using FSH.Modules.Chat.Localization; using Mediator; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -20,16 +21,24 @@ public async ValueTask Handle(RemoveReactionCommand cmd, CancellationToken { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); var message = await db.Messages.FirstOrDefaultAsync(m => m.Id == cmd.MessageId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == message.ChannelId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Message not found."); + ?? throw new NotFoundException("Message not found.") + { + MessageKey = "Chat.MessageNotFound", + ResourceSource = typeof(ChatResources), + }; channel.RequireMember(currentUserId); if (!message.RemoveReaction(currentUserId, cmd.Emoji)) diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs index 0f1ce68f26..e68b402953 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs @@ -23,7 +23,7 @@ public async ValueTask> Handle( { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) throw new UnauthorizedException("no current user") { MessageKey = "Error.NoCurrentUser" }; var currentUserId = userId.ToString(); int page = Math.Max(1, query.Page); diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs new file mode 100644 index 0000000000..bfdb7f34ea --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Chat.Localization; + +/// Marker type binding IStringLocalizer<ChatResources> to the Chat resx catalog. +public sealed class ChatResources; diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx new file mode 100644 index 0000000000..0713cbd0b5 --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.pt-BR.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Canal não encontrado. + + + É necessário ser administrador do canal. + + + Apenas administradores do canal podem adicionar membros a canais privados. + + + Mensagem não encontrada. + + + Mensagem não encontrada neste canal. + + + Mensagem pai não encontrada. + + + A mensagem pai pertence a outro canal. + + + Não é possível responder a uma resposta; as threads têm apenas um nível. + + + Não é possível iniciar uma conversa direta consigo mesmo. + + + É necessário informar um texto ou um anexo. + + diff --git a/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx new file mode 100644 index 0000000000..5319e61aa1 --- /dev/null +++ b/src/Modules/Chat/Modules.Chat/Localization/ChatResources.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Channel not found. + + + Channel admin role required. + + + Only channel admins can add members to private channels. + + + Message not found. + + + Message not found in this channel. + + + Parent message not found. + + + Parent message belongs to a different channel. + + + Cannot reply to a reply — threads are single-level only. + + + Cannot DM yourself. + + + Either a body or an attachment is required. + + diff --git a/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj b/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj index 80aba39d01..12383f226e 100644 --- a/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj +++ b/src/Modules/Chat/Modules.Chat/Modules.Chat.csproj @@ -3,7 +3,8 @@ FSH.Modules.Chat FSH.Modules.Chat - $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267;S2094 diff --git a/src/Modules/Files/Modules.Files/Domain/FileAsset.cs b/src/Modules/Files/Modules.Files/Domain/FileAsset.cs index f8c8e150d7..f1630df52c 100644 --- a/src/Modules/Files/Modules.Files/Domain/FileAsset.cs +++ b/src/Modules/Files/Modules.Files/Domain/FileAsset.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Domain.Events; +using FSH.Modules.Files.Localization; namespace FSH.Modules.Files.Domain; @@ -87,7 +88,12 @@ public void MarkAvailable(long actualSize, ScanStatus scanResult) throw new CustomException( $"Cannot finalize file in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Files.CannotFinalizeInStatus", + MessageArgs = [Status], + ResourceSource = typeof(FilesResources), + }; } if (actualSize <= 0) { @@ -126,7 +132,12 @@ public void ChangeVisibility(Visibility next) throw new CustomException( $"Cannot change visibility while file is in status {Status}.", errors: null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Files.CannotChangeVisibilityInStatus", + MessageArgs = [Status], + ResourceSource = typeof(FilesResources), + }; } if (Visibility == next) return; Visibility = next; diff --git a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs index 91f37c1f02..9713652697 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandHandler.cs @@ -7,6 +7,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -29,21 +30,38 @@ public async ValueTask Handle(ChangeFileVisibilityCommand cmd, Can throw new CustomException( $"Unknown visibility value '{cmd.Visibility}'.", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Files.UnknownVisibility", + MessageArgs = [cmd.Visibility], + ResourceSource = typeof(FilesResources), + }; } var f = await db.FileAssets .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy") + { + MessageKey = "Files.NoAccessPolicy", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanChangeVisibilityAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("not allowed to change this file's visibility"); + throw new ForbiddenException("not allowed to change this file's visibility") + { + MessageKey = "Files.NotAllowedToChangeVisibility", + ResourceSource = typeof(FilesResources), + }; } f.ChangeVisibility(cmd.Visibility); diff --git a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs index 7cf3f3d510..07057289ab 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ChangeVisibility/ChangeFileVisibilityCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; using FSH.Modules.Files.Contracts.v1.Commands; +using FSH.Modules.Files.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Files.Features.v1.ChangeVisibility; public sealed class ChangeFileVisibilityCommandValidator : AbstractValidator { - public ChangeFileVisibilityCommandValidator() + public ChangeFileVisibilityCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.FileAssetId).NotEmpty(); RuleFor(x => x.Visibility) .IsInEnum() - .WithMessage("Visibility must be Public or Private."); + .WithMessage(_ => localizer["Files.VisibilityInvalid"]); } } diff --git a/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs index 95190356a0..cfea26f961 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/DeleteFile/DeleteFileCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Files.Contracts; using FSH.Modules.Files.Contracts.v1.Commands; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,15 +23,27 @@ public async ValueTask Handle(DeleteFileCommand cmd, CancellationToken can var f = await db.FileAssets .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new ForbiddenException("no policy"); + ?? throw new ForbiddenException("no policy") + { + MessageKey = "Files.NoAccessPolicy", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanDeleteAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("not allowed to delete this file"); + throw new ForbiddenException("not allowed to delete this file") + { + MessageKey = "Files.NotAllowedToDelete", + ResourceSource = typeof(FilesResources), + }; } // Soft-delete: AuditableEntitySaveChangesInterceptor sets IsDeleted/DeletedOnUtc/DeletedBy on diff --git a/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs index 8288acbe42..26a72648d9 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs @@ -12,6 +12,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -30,25 +31,44 @@ public sealed class FinalizeUploadCommandHandler( public async ValueTask Handle(FinalizeUploadCommand cmd, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(cmd); - var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant"); + var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; var userId = currentUser.GetUserId().ToString(); var asset = await db.FileAssets .FirstOrDefaultAsync(f => f.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; if (!string.Equals(asset.CreatedByUserId, userId, StringComparison.Ordinal)) { - throw new ForbiddenException("not your pending file"); + throw new ForbiddenException("not your pending file") + { + MessageKey = "Files.NotYourPendingFile", + ResourceSource = typeof(FilesResources), + }; } if (asset.Status != FileAssetStatus.PendingUpload) { - throw new CustomException("file already finalized", (IEnumerable?)null, HttpStatusCode.Conflict); + throw new CustomException("file already finalized", (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Files.AlreadyFinalized", + ResourceSource = typeof(FilesResources), + }; } var head = await storage.HeadObjectAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false) - ?? throw new CustomException("upload not received", (IEnumerable?)null, HttpStatusCode.Conflict); + ?? throw new CustomException("upload not received", (IEnumerable?)null, HttpStatusCode.Conflict) + { + MessageKey = "Files.UploadNotReceived", + ResourceSource = typeof(FilesResources), + }; // Allow declared+1% slack (S3 may differ slightly on multipart). Reject larger sizes. var maxAllowed = asset.SizeBytes + Math.Max(1024L, asset.SizeBytes / 100); @@ -60,7 +80,12 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( $"uploaded size ({head.SizeBytes}) exceeds declared ({asset.SizeBytes})", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.UploadedSizeExceedsDeclared", + MessageArgs = [head.SizeBytes, asset.SizeBytes], + ResourceSource = typeof(FilesResources), + }; } if (!string.Equals(head.ContentType, asset.ContentType, StringComparison.OrdinalIgnoreCase)) @@ -71,7 +96,11 @@ public async ValueTask Handle(FinalizeUploadCommand cmd, Cancellat throw new CustomException( "uploaded content-type mismatch", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.ContentTypeMismatch", + ResourceSource = typeof(FilesResources), + }; } var scanResult = await scanner.ScanAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs index 7dd4d06b64..8a0adace50 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/GetFileDownloadUrl/GetFileDownloadUrlQueryHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Contracts.v1.Queries; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,16 +28,28 @@ public async ValueTask Handle(GetFileDownloadUrlQuery var f = await db.FileAssets.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == q.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); var policy = policies.Resolve(f.OwnerType) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanReadAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new NotFoundException("file not found"); + throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; } var ttl = TimeSpan.FromMinutes(options.Value.DownloadUrlTtlMinutes); diff --git a/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs index 32f8366244..cf8a85a80f 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/GetFileMetadata/GetFileMetadataQueryHandler.cs @@ -7,6 +7,7 @@ using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; using FSH.Modules.Files.Features.v1.Internal; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,16 +28,29 @@ public async ValueTask Handle(GetFileMetadataQuery q, Cancellation var f = await db.FileAssets.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == q.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var userId = currentUser.GetUserId().ToString(); + // don't leak existence on missing policy var policy = policies.Resolve(f.OwnerType) - ?? throw new NotFoundException("file not found"); // don't leak existence on missing policy + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; var ctx = new FileAccessContext(f.Id, f.OwnerType, f.OwnerId, f.CreatedByUserId, (int)f.Visibility); if (!await policy.CanReadAsync(ctx, userId, cancellationToken).ConfigureAwait(false)) { - throw new NotFoundException("file not found"); + throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; } // Public files get a durable URL safe to persist long-term, while private files mint a diff --git a/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs index 935c5a13ed..72b99dccb1 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/ListMyFiles/ListMyFilesQueryHandler.cs @@ -24,7 +24,10 @@ public async ValueTask> Handle(ListMyFilesQuery var userId = currentUser.GetUserId().ToString(); if (string.IsNullOrEmpty(userId) || userId == Guid.Empty.ToString()) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } var page = Math.Max(1, q.Page); diff --git a/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs index c5d837cd14..4ba9696d50 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/RequestUploadUrl/RequestUploadUrlCommandHandler.cs @@ -9,6 +9,7 @@ using FSH.Modules.Files.Contracts.v1.DTOs; using FSH.Modules.Files.Data; using FSH.Modules.Files.Domain; +using FSH.Modules.Files.Localization; using FSH.Modules.Files.Services; using Mediator; using Microsoft.Extensions.Options; @@ -28,17 +29,28 @@ public async ValueTask Handle(RequestUploadUrlCommand c { ArgumentNullException.ThrowIfNull(cmd); - var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant"); + var tenantId = currentUser.GetTenant() ?? throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; var userId = currentUser.GetUserId(); if (userId == Guid.Empty) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } // Category lookup + extension/size validation. if (!options.Value.Categories.TryGetValue(cmd.Category, out var category)) { - throw new CustomException($"Unknown category '{cmd.Category}'.", (IEnumerable?)null, HttpStatusCode.BadRequest); + throw new CustomException($"Unknown category '{cmd.Category}'.", (IEnumerable?)null, HttpStatusCode.BadRequest) + { + MessageKey = "Files.UnknownCategory", + MessageArgs = [cmd.Category], + ResourceSource = typeof(FilesResources), + }; } var extension = Path.GetExtension(cmd.FileName); @@ -48,7 +60,12 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Extension '{extension}' not allowed for category '{cmd.Category}'.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.ExtensionNotAllowed", + MessageArgs = [extension, cmd.Category], + ResourceSource = typeof(FilesResources), + }; } if (cmd.SizeBytes > category.MaxBytes) @@ -56,15 +73,29 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"File exceeds max size of {category.MaxBytes} bytes for category '{cmd.Category}'.", (IEnumerable?)null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Files.FileExceedsMaxSize", + MessageArgs = [category.MaxBytes, cmd.Category], + ResourceSource = typeof(FilesResources), + }; } // Authorization: policy must exist and allow the attach. var policy = policies.Resolve(cmd.OwnerType) - ?? throw new ForbiddenException($"No file access policy registered for owner type '{cmd.OwnerType}'."); + ?? throw new ForbiddenException($"No file access policy registered for owner type '{cmd.OwnerType}'.") + { + MessageKey = "Files.NoPolicyForOwnerType", + MessageArgs = [cmd.OwnerType], + ResourceSource = typeof(FilesResources), + }; if (!await policy.CanAttachAsync(cmd.OwnerId, userId.ToString(), cancellationToken).ConfigureAwait(false)) { - throw new ForbiddenException("Not allowed to attach files to this owner."); + throw new ForbiddenException("Not allowed to attach files to this owner.") + { + MessageKey = "Files.NotAllowedToAttach", + ResourceSource = typeof(FilesResources), + }; } // Quota pre-check (no debit yet — debit happens on finalize with actual bytes). @@ -74,7 +105,12 @@ public async ValueTask Handle(RequestUploadUrlCommand c throw new CustomException( $"Storage quota exceeded ({quotaCheck.CurrentUsage}/{quotaCheck.Limit} bytes).", (IEnumerable?)null, - (HttpStatusCode)507); + (HttpStatusCode)507) + { + MessageKey = "Files.StorageQuotaExceeded", + MessageArgs = [quotaCheck.CurrentUsage, quotaCheck.Limit], + ResourceSource = typeof(FilesResources), + }; } // Generate id + storage key + presigned URL. diff --git a/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs b/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs index 724cd21e70..561948afce 100644 --- a/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs +++ b/src/Modules/Files/Modules.Files/Features/v1/RestoreFile/RestoreFileCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Files.Contracts.v1.Commands; using FSH.Modules.Files.Data; +using FSH.Modules.Files.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -18,7 +19,11 @@ public async ValueTask Handle(RestoreFileCommand cmd, CancellationToken ca .IgnoreQueryFilters() .FirstOrDefaultAsync(x => x.Id == cmd.FileAssetId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("file not found"); + ?? throw new NotFoundException("file not found") + { + MessageKey = "Files.FileNotFound", + ResourceSource = typeof(FilesResources), + }; if (!f.IsDeleted) { diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.cs b/src/Modules/Files/Modules.Files/Localization/FilesResources.cs new file mode 100644 index 0000000000..0c66119c6c --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Files.Localization; + +/// Marker type binding IStringLocalizer<FilesResources> to the Files resx catalog. +public sealed class FilesResources; diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx new file mode 100644 index 0000000000..14feeb0afe --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Não é possível finalizar o arquivo no status {0}. + + + Não é possível alterar a visibilidade enquanto o arquivo está no status {0}. + + + Valor de visibilidade desconhecido '{0}'. + + + Arquivo não encontrado. + + + Nenhuma política de acesso a arquivos registrada. + + + Você não tem permissão para alterar a visibilidade deste arquivo. + + + Você não tem permissão para excluir este arquivo. + + + Este arquivo pendente não pertence a você. + + + O arquivo já foi finalizado. + + + O upload não foi recebido. + + + O tamanho enviado ({0}) excede o tamanho declarado ({1}). + + + O tipo de conteúdo enviado não corresponde. + + + Categoria desconhecida '{0}'. + + + A extensão '{0}' não é permitida para a categoria '{1}'. + + + O arquivo excede o tamanho máximo de {0} bytes para a categoria '{1}'. + + + Nenhuma política de acesso a arquivos registrada para o tipo de proprietário '{0}'. + + + Você não tem permissão para anexar arquivos a este proprietário. + + + Cota de armazenamento excedida ({0}/{1} bytes). + + + A visibilidade deve ser Pública ou Privada. + + diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx new file mode 100644 index 0000000000..e0116adf4e --- /dev/null +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cannot finalize file in status {0}. + + + Cannot change visibility while file is in status {0}. + + + Unknown visibility value '{0}'. + + + File not found. + + + No file access policy is registered. + + + You are not allowed to change this file's visibility. + + + You are not allowed to delete this file. + + + This pending file does not belong to you. + + + File is already finalized. + + + Upload was not received. + + + Uploaded size ({0}) exceeds the declared size ({1}). + + + Uploaded content type does not match. + + + Unknown category '{0}'. + + + Extension '{0}' is not allowed for category '{1}'. + + + File exceeds the maximum size of {0} bytes for category '{1}'. + + + No file access policy is registered for owner type '{0}'. + + + You are not allowed to attach files to this owner. + + + Storage quota exceeded ({0}/{1} bytes). + + + Visibility must be Public or Private. + + diff --git a/src/Modules/Files/Modules.Files/Modules.Files.csproj b/src/Modules/Files/Modules.Files/Modules.Files.csproj index d259693c85..83d34fe5ff 100644 --- a/src/Modules/Files/Modules.Files/Modules.Files.csproj +++ b/src/Modules/Files/Modules.Files/Modules.Files.csproj @@ -3,7 +3,8 @@ FSH.Modules.Files FSH.Modules.Files - $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;CA1002;CA2227;S3267;S2094 diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs index feb1a05203..fc8a3185a9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -32,7 +33,12 @@ public async ValueTask Handle(AddUsersToGroupCommand co if (!groupExists) { - throw new NotFoundException($"Group with ID '{command.GroupId}' not found."); + throw new NotFoundException($"Group with ID '{command.GroupId}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [command.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Validate user IDs exist @@ -44,7 +50,12 @@ public async ValueTask Handle(AddUsersToGroupCommand co var invalidUserIds = command.UserIds.Except(existingUserIds).ToList(); if (invalidUserIds.Count > 0) { - throw new NotFoundException($"Users not found: {string.Join(", ", invalidUserIds)}"); + throw new NotFoundException($"Users not found: {string.Join(", ", invalidUserIds)}") + { + MessageKey = "Identity.UsersNotFound", + MessageArgs = [string.Join(", ", invalidUserIds)], + ResourceSource = typeof(IdentityResources), + }; } // Get existing memberships diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs index e90a9a564d..6c15fbcb9f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs @@ -1,18 +1,20 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; public sealed class AddUsersToGroupCommandValidator : AbstractValidator { - public AddUsersToGroupCommandValidator() + public AddUsersToGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserIds) - .NotEmpty().WithMessage("At least one user ID is required.") + .NotEmpty().WithMessage(_ => localizer["Validation.AtLeastOneUserIdRequired"]) .Must(ids => ids.All(id => !string.IsNullOrWhiteSpace(id))) - .WithMessage("User IDs cannot be empty or whitespace."); + .WithMessage(_ => localizer["Validation.UserIdsNotEmptyOrWhitespace"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs index d0e1493737..c933d7512b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -30,7 +31,12 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation if (nameExists) { - throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + throw new CustomException($"Group with name '{command.Name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict) + { + MessageKey = "Identity.GroupNameAlreadyExists", + MessageArgs = [command.Name], + ResourceSource = typeof(IdentityResources), + }; } // Validate role IDs exist — fetch Id+Name in a single query to avoid a second roundtrip later @@ -46,7 +52,12 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation var invalidRoleIds = command.RoleIds.Except(resolvedRoles.Select(r => r.Id)).ToList(); if (invalidRoleIds.Count > 0) { - throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}") + { + MessageKey = "Identity.RolesNotFoundWithIds", + MessageArgs = [string.Join(", ", invalidRoleIds)], + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs index 42a8dd668e..f112b51976 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; public sealed class CreateGroupCommandValidator : AbstractValidator { - public CreateGroupCommandValidator() + public CreateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs index 37626d12d4..3143680330 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -27,11 +28,20 @@ public async ValueTask Handle(DeleteGroupCommand command, CancellationToke var group = await _dbContext.Groups .FirstOrDefaultAsync(g => g.Id == command.Id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{command.Id}' not found."); + ?? throw new NotFoundException($"Group with ID '{command.Id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(IdentityResources), + }; if (group.IsSystemGroup) { - throw new ForbiddenException("System groups cannot be deleted."); + throw new ForbiddenException("System groups cannot be deleted.") + { + MessageKey = "Identity.SystemGroupsCannotBeDeleted", + ResourceSource = typeof(IdentityResources), + }; } // Snapshot members before delete; soft-delete flips IsDeleted but membership rows diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs index 4805b8769a..4d2834c5a7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; public sealed class DeleteGroupCommandValidator : AbstractValidator { - public DeleteGroupCommandValidator() + public DeleteGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs index bc29827213..3f6683ae62 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -22,7 +23,12 @@ public async ValueTask Handle(GetGroupByIdQuery query, CancellationTok .AsNoTracking() .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{query.Id}' not found."); + ?? throw new NotFoundException($"Group with ID '{query.Id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [query.Id], + ResourceSource = typeof(IdentityResources), + }; var memberCount = await _dbContext.UserGroups .AsNoTracking() diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs index a8689ceb7b..1de6594088 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Groups.GetGroupMembers; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask> Handle(GetGroupMembersQuery if (!groupExists) { - throw new NotFoundException($"Group with ID '{query.GroupId}' not found."); + throw new NotFoundException($"Group with ID '{query.GroupId}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [query.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Get memberships with user info diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs index e10d1eee9c..21e614d0ab 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -28,14 +29,23 @@ public async ValueTask Handle(RemoveUserFromGroupCommand command, Cancella if (membership is null) { - throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'."); + throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'.") + { + MessageKey = "Identity.UserNotMemberOfGroup", + MessageArgs = [command.UserId, command.GroupId], + ResourceSource = typeof(IdentityResources), + }; } // Default groups (e.g. seeded "All Users") require every tenant user to be a member, so // removing one breaks that invariant and leaves later registrants in a half-populated group. if (membership.Group is not null && membership.Group.IsDefault) { - throw new ForbiddenException("Users cannot be removed from a default group."); + throw new ForbiddenException("Users cannot be removed from a default group.") + { + MessageKey = "Identity.CannotRemoveFromDefaultGroup", + ResourceSource = typeof(IdentityResources), + }; } _dbContext.UserGroups.Remove(membership); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs index da5ce2bd3d..418b3210ff 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; public sealed class RemoveUserFromGroupCommandValidator : AbstractValidator { - public RemoveUserFromGroupCommandValidator() + public RemoveUserFromGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs index 37d6e00c1a..f8c7b331d9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -33,7 +34,11 @@ public async ValueTask Handle(UpdateGroupCommand command, Cancellation // assignments are all part of the seed contract that the startup syncer relies on. if (group.IsSystemGroup) { - throw new ForbiddenException("System groups cannot be modified."); + throw new ForbiddenException("System groups cannot be modified.") + { + MessageKey = "Identity.SystemGroupsCannotBeModified", + ResourceSource = typeof(IdentityResources), + }; } await ValidateUniqueNameAsync(command.Id, command.Name, cancellationToken); @@ -69,7 +74,12 @@ private async Task GetGroupAsync(Guid id, CancellationToken cancellationT return await _dbContext.Groups .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == id, cancellationToken) - ?? throw new NotFoundException($"Group with ID '{id}' not found."); + ?? throw new NotFoundException($"Group with ID '{id}' not found.") + { + MessageKey = "Identity.GroupNotFound", + MessageArgs = [id], + ResourceSource = typeof(IdentityResources), + }; } private async Task ValidateUniqueNameAsync(Guid excludeId, string name, CancellationToken cancellationToken) @@ -79,7 +89,12 @@ private async Task ValidateUniqueNameAsync(Guid excludeId, string name, Cancella if (nameExists) { - throw new CustomException($"Group with name '{name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict); + throw new CustomException($"Group with name '{name}' already exists.", (IEnumerable?)null, System.Net.HttpStatusCode.Conflict) + { + MessageKey = "Identity.GroupNameAlreadyExists", + MessageArgs = [name], + ResourceSource = typeof(IdentityResources), + }; } } @@ -98,7 +113,12 @@ private async Task ValidateRoleIdsAsync(IReadOnlyList? roleIds, Cancella var invalidRoleIds = roleIds.Except(existingRoleIds).ToList(); if (invalidRoleIds.Count > 0) { - throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}"); + throw new NotFoundException($"Roles not found: {string.Join(", ", invalidRoleIds)}") + { + MessageKey = "Identity.RolesNotFoundWithIds", + MessageArgs = [string.Join(", ", invalidRoleIds)], + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs index 4c111e0c05..3442077ddc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; public sealed class UpdateGroupCommandValidator : AbstractValidator { - public UpdateGroupCommandValidator() + public UpdateGroupCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupIdRequired"]); RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.GroupNameRequired"]) + .MaximumLength(256).WithMessage(_ => localizer["Validation.GroupNameMaxLength"]); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(_ => localizer["Validation.DescriptionMaxLength"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs index d731e131fb..08509554e3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/EndImpersonation/EndImpersonationCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation.EndImpersonation; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -66,7 +67,11 @@ public async ValueTask Handle( throw new CustomException( "current session is not an impersonation session", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.NotAnImpersonationSession", + ResourceSource = typeof(IdentityResources), + }; } var impersonatedUserId = _currentUser.GetUserId().ToString(); @@ -93,7 +98,11 @@ public async ValueTask Handle( if (actorClaimsResult is null) { - throw new NotFoundException("original actor not found"); + throw new NotFoundException("original actor not found") + { + MessageKey = "Identity.OriginalActorNotFound", + ResourceSource = typeof(IdentityResources), + }; } var (subject, actorClaims) = actorClaimsResult.Value; diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs index 65b8377a41..20e4b57dfe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryHandler.cs @@ -20,7 +20,10 @@ public async ValueTask> Handle( ArgumentNullException.ThrowIfNull(request); var callerTenant = currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var isRoot = string.Equals(callerTenant, MultitenancyConstants.Root.Id, StringComparison.Ordinal); // Tenant scoping: root operators target any tenant; tenant admins are locked to their diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs index 912dff296d..8dd4bac96f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/GetImpersonationGrants/GetImpersonationGrantsQueryValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Impersonation.GetImpersonationGrants; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Impersonation.GetImpersonationGrants; @@ -7,11 +9,11 @@ public sealed class GetImpersonationGrantsQueryValidator : AbstractValidator localizer) { RuleFor(q => q.Take) .GreaterThan(0) .LessThanOrEqualTo(MaxTake) - .WithMessage($"Take must be between 1 and {MaxTake}."); + .WithMessage(_ => localizer["Validation.ImpersonationTakeRange", MaxTake]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs index 9dd7a8a24a..aaa859e5f0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/RevokeImpersonationGrant/RevokeImpersonationGrantCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Contracts.v1.Impersonation.RevokeImpersonationGrant; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; @@ -31,20 +32,31 @@ public async ValueTask Handle( var callerUserId = currentUser.GetUserId().ToString(); var callerTenantId = currentUser.GetTenant() - ?? throw new UnauthorizedException("missing tenant context"); + ?? throw new UnauthorizedException("missing tenant context") + { + MessageKey = "Error.InvalidTenant", + }; var isRoot = string.Equals(callerTenantId, MultitenancyConstants.Root.Id, StringComparison.Ordinal); // Enforce visibility before revoking: tenant admins may only revoke grants in their own // tenant. Cross-tenant grants return 404 (not 403) so existence isn't confirmed out of scope. var grant = await grantService.GetByIdAsync(request.GrantId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException("impersonation grant not found"); + ?? throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; var withinTenant = string.Equals(grant.ImpersonatedTenantId, callerTenantId, StringComparison.Ordinal) || string.Equals(grant.ActorTenantId, callerTenantId, StringComparison.Ordinal); if (!isRoot && !withinTenant) { - throw new NotFoundException("impersonation grant not found"); + throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; } var updated = await grantService.RevokeAsync( diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs index 69647afe7a..6a2f8cafd3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandValidator.cs @@ -1,5 +1,7 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Impersonation.StartImpersonation; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Impersonation.StartImpersonation; @@ -12,7 +14,7 @@ public sealed class StartImpersonationCommandValidator : AbstractValidator public const int MaxImpersonationMinutes = 60; - public StartImpersonationCommandValidator() + public StartImpersonationCommandValidator(IStringLocalizer localizer) { RuleFor(p => p.TargetUserId) .Cascade(CascadeMode.Stop) @@ -25,7 +27,7 @@ public StartImpersonationCommandValidator() RuleFor(p => p.DurationMinutes!.Value) .GreaterThan(0) .LessThanOrEqualTo(MaxImpersonationMinutes) - .WithMessage($"Duration must be between 1 and {MaxImpersonationMinutes} minutes.") + .WithMessage(_ => localizer["Validation.ImpersonationDurationRange", MaxImpersonationMinutes]) .When(p => p.DurationMinutes.HasValue); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs index bf213c86a5..a015186dbc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; public sealed class DeleteRoleCommandValidator : AbstractValidator { - public DeleteRoleCommandValidator() + public DeleteRoleCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("Role ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.RoleIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs index 183ad47f34..5a51837f1a 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.GetRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.GetRoles; public sealed class GetRolesQueryValidator : AbstractValidator { - public GetRolesQueryValidator() + public GetRolesQueryValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs index 70fb1e8cc6..6083ff757e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs @@ -9,6 +9,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -57,10 +58,18 @@ public async Task> GetRolesAsync( CancellationToken cancellationToken = default) { if (roleManager is null) - throw new NotFoundException("RoleManager not resolved. Check Identity registration."); + throw new NotFoundException("RoleManager not resolved. Check Identity registration.") + { + MessageKey = "Identity.RoleManagerNotResolved", + ResourceSource = typeof(IdentityResources), + }; if (roleManager.Roles is null) - throw new NotFoundException("Role store not configured. Ensure .AddRoles() and EF stores."); + throw new NotFoundException("Role store not configured. Ensure .AddRoles() and EF stores.") + { + MessageKey = "Identity.RoleStoreNotConfigured", + ResourceSource = typeof(IdentityResources), + }; var page = Math.Max(1, pageNumber); var size = Math.Clamp(pageSize, 1, 200); @@ -97,7 +106,11 @@ public async Task> GetRolesAsync( { FshRole? role = await roleManager.FindByIdAsync(id); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; return new RoleDto { Id = role.Id, Name = role.Name!, Description = role.Description }; } @@ -111,9 +124,9 @@ public async Task CreateOrUpdateRoleAsync(string roleId, string name, s if (role != null) { // System roles cannot be modified — neither renamed nor re-described. - EnsureNotSystemRole(role.Name, "System roles cannot be modified."); + EnsureNotSystemRole(role.Name, "System roles cannot be modified.", "Identity.SystemRoleCannotBeModified"); // And no custom role can be renamed to a system role's name. - EnsureNotSystemRole(name, "Cannot rename a role to a system role's name."); + EnsureNotSystemRole(name, "Cannot rename a role to a system role's name.", "Identity.CannotRenameToSystemRole"); role.Name = name; role.Description = description; @@ -122,7 +135,7 @@ public async Task CreateOrUpdateRoleAsync(string roleId, string name, s else { // No new role can be created using a system role's name. - EnsureNotSystemRole(name, "Cannot create a role using a system role's name."); + EnsureNotSystemRole(name, "Cannot create a role using a system role's name.", "Identity.CannotCreateWithSystemRoleName"); role = new FshRole(name, description); await roleManager.CreateAsync(role); @@ -135,9 +148,13 @@ public async Task DeleteRoleAsync(string id, CancellationToken cancellationToken { FshRole? role = await roleManager.FindByIdAsync(id); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; - EnsureNotSystemRole(role.Name, "System roles cannot be deleted."); + EnsureNotSystemRole(role.Name, "System roles cannot be deleted.", "Identity.SystemRolesCannotBeDeleted"); // Snapshot affected users BEFORE the cascade removes the role-mapping rows, // otherwise the lookup returns an empty set after delete. @@ -149,7 +166,11 @@ public async Task DeleteRoleAsync(string id, CancellationToken cancellationToken public async Task GetWithPermissionsAsync(string id, CancellationToken cancellationToken = default) { var role = await GetRoleAsync(id, cancellationToken); - _ = role ?? throw new NotFoundException("role not found"); + _ = role ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; role.Permissions = await context.RoleClaims .AsNoTracking() @@ -165,9 +186,13 @@ public async Task UpdatePermissionsAsync(string roleId, List per ArgumentNullException.ThrowIfNull(permissions); var role = await roleManager.FindByIdAsync(roleId) - ?? throw new NotFoundException("role not found"); + ?? throw new NotFoundException("role not found") + { + MessageKey = "Identity.RoleNotFound", + ResourceSource = typeof(IdentityResources), + }; - EnsureNotSystemRole(role.Name, "System role permissions are managed by the framework and cannot be modified."); + EnsureNotSystemRole(role.Name, "System role permissions are managed by the framework and cannot be modified.", "Identity.SystemRolePermissionsManaged"); FilterRootPermissions(permissions); var currentClaims = await roleManager.GetClaimsAsync(role); @@ -181,11 +206,15 @@ public async Task UpdatePermissionsAsync(string roleId, List per return "permissions updated"; } - private static void EnsureNotSystemRole(string? roleName, string message) + private static void EnsureNotSystemRole(string? roleName, string message, string messageKey) { if (!string.IsNullOrEmpty(roleName) && RoleConstants.IsDefault(roleName)) { - throw new CustomException(message, Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException(message, Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = messageKey, + ResourceSource = typeof(IdentityResources), + }; } } @@ -214,7 +243,11 @@ private async Task RemoveRevokedPermissionsAsync(FshRole role, IList error.Description).ToList(); - throw new CustomException("operation failed", errors); + throw new CustomException("operation failed", errors) + { + MessageKey = "Identity.OperationFailed", + ResourceSource = typeof(IdentityResources), + }; } } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs index e206420a88..94f45bd2c2 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs @@ -1,12 +1,14 @@ -using FluentValidation; +using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; public sealed class UpsertRoleCommandValidator : AbstractValidator { - public UpsertRoleCommandValidator() + public UpsertRoleCommandValidator(IStringLocalizer localizer) { - RuleFor(x => x.Name).NotEmpty().WithMessage("Role name is required."); + RuleFor(x => x.Name).NotEmpty().WithMessage(_ => localizer["Validation.RoleNameRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs index f9b8449cb6..3e642608fb 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; public sealed class AdminRevokeAllSessionsCommandValidator : AbstractValidator { - public AdminRevokeAllSessionsCommandValidator() + public AdminRevokeAllSessionsCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs index 8e59cb4ec9..f4edf82bbf 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs @@ -1,20 +1,22 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; public sealed class AdminRevokeSessionCommandValidator : AbstractValidator { - public AdminRevokeSessionCommandValidator() + public AdminRevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(_ => localizer["Validation.ReasonMaxLength"]) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs index 06b3786ba8..95bbf174d7 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetTenantSessions/GetTenantSessionsValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.GetTenantSessions; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.GetTenantSessions; public sealed class GetTenantSessionsValidator : AbstractValidator { - public GetTenantSessionsValidator() + public GetTenantSessionsValidator(IStringLocalizer localizer) { RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1).WithMessage("Page number must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageNumberMinimum"]); RuleFor(x => x.PageSize) - .GreaterThanOrEqualTo(1).WithMessage("Page size must be greater than or equal to 1."); + .GreaterThanOrEqualTo(1).WithMessage(_ => localizer["Validation.PageSizeMinimum"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs index c0ceb12b33..d5e79ba14d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; public sealed class RevokeSessionCommandValidator : AbstractValidator { - public RevokeSessionCommandValidator() + public RevokeSessionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.SessionIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs index dd66630e31..543fe28a71 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.Tokens.RefreshToken; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.IdentityModel.Tokens.Jwt; @@ -51,7 +52,11 @@ public async ValueTask Handle( if (validated is null) { await _securityAudit.TokenRevokedAsync("unknown", clientId!, "InvalidRefreshToken", cancellationToken); - throw new UnauthorizedException("Invalid refresh token."); + throw new UnauthorizedException("Invalid refresh token.") + { + MessageKey = "Identity.InvalidRefreshToken", + ResourceSource = typeof(IdentityResources), + }; } var (subject, claims) = validated.Value; @@ -62,7 +67,11 @@ public async ValueTask Handle( if (!isSessionValid) { await _securityAudit.TokenRevokedAsync(subject, clientId!, "SessionRevoked", cancellationToken); - throw new UnauthorizedException("Session has been revoked."); + throw new UnauthorizedException("Session has been revoked.") + { + MessageKey = "Identity.SessionRevoked", + ResourceSource = typeof(IdentityResources), + }; } // Optionally, cross-check the provided access token subject @@ -87,7 +96,11 @@ public async ValueTask Handle( !string.Equals(accessTokenSubject, subject, StringComparison.Ordinal)) { await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenSubjectMismatch", cancellationToken); - throw new UnauthorizedException("Access token subject mismatch."); + throw new UnauthorizedException("Access token subject mismatch.") + { + MessageKey = "Identity.AccessTokenSubjectMismatch", + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs index 24bffb7030..3b90223383 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs @@ -1,5 +1,6 @@ using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Context; +using FSH.Framework.Core.Exceptions; using FSH.Framework.Eventing.Outbox; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Auditing.Contracts; @@ -7,6 +8,7 @@ using FSH.Modules.Identity.Contracts.Events; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.Extensions.Logging; using System.Security.Claims; @@ -70,7 +72,11 @@ await _securityAudit.LoginFailedAsync( ip: ip, ct: cancellationToken); - throw new UnauthorizedAccessException("Invalid credentials."); + throw new LocalizedUnauthorizedAccessException("Invalid credentials.") + { + MessageKey = "Identity.InvalidCredentials", + ResourceSource = typeof(IdentityResources), + }; } // Unpack subject + claims diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs index 4d7bd0dd67..11601944e0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenEndpoint.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Tokens.TokenGeneration; @@ -8,6 +9,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; using System.ComponentModel; namespace FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; @@ -35,14 +37,15 @@ [AllowAnonymous] async Task, UnauthorizedHttpResult, P [DefaultValue("root")][FromHeader] string tenant, [FromHeader(Name = AppHeader)] string? app, [FromServices] IMediator mediator, + [FromServices] IStringLocalizer localizer, CancellationToken ct) => { if (IsRootViaDashboard(tenant, app)) { return TypedResults.Problem( statusCode: StatusCodes.Status403Forbidden, - title: "App boundary", - detail: "SuperAdmin accounts must use the admin app. Sign in there instead of the tenant dashboard."); + title: localizer["Error.AppBoundary"], + detail: localizer["Error.AppBoundary.Detail"]); } var token = await mediator.Send(command, ct); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs index fa7c7ff9b4..0f000f2fcd 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Disable/DisableTwoFactorCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -31,13 +32,22 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Require current password so a stolen access token alone can't downgrade // account security. if (!await _userManager.CheckPasswordAsync(user, command.CurrentPassword)) { - throw new UnauthorizedException("Current password is incorrect."); + throw new UnauthorizedException("Current password is incorrect.") + { + MessageKey = "Identity.CurrentPasswordIncorrect", + ResourceSource = typeof(IdentityResources), + }; } await _userManager.SetTwoFactorEnabledAsync(user, false); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs index 2a95e8678c..39ccfdf2f0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/Enroll/EnrollTwoFactorCommandHandler.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -35,13 +36,22 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Always reset so calling enroll twice rotates the secret — prevents stale codes // from a prior incomplete enrollment from silently succeeding. await _userManager.ResetAuthenticatorKeyAsync(user); var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user) - ?? throw new CustomException("Failed to generate authenticator key."); + ?? throw new CustomException("Failed to generate authenticator key.") + { + MessageKey = "Identity.FailedToGenerateAuthenticatorKey", + ResourceSource = typeof(IdentityResources), + }; var email = user.Email ?? user.UserName ?? user.Id; var authenticatorUri = string.Format( diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs index edc6a52ac9..151ed497c6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/TwoFactor/VerifyEnroll/VerifyEnrollTwoFactorCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Identity.Contracts.v1.TwoFactor; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.AspNetCore.Identity; @@ -31,7 +32,12 @@ public async ValueTask Handle( var userId = _currentUser.GetUserId().ToString(); var user = await _userManager.FindByIdAsync(userId) - ?? throw new NotFoundException($"User {userId} not found."); + ?? throw new NotFoundException($"User {userId} not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; var sanitized = command.Code.Replace(" ", string.Empty, StringComparison.Ordinal); var valid = await _userManager.VerifyTwoFactorTokenAsync( @@ -44,7 +50,11 @@ public async ValueTask Handle( throw new CustomException( "The authenticator code is invalid.", errors: null, - System.Net.HttpStatusCode.BadRequest); + System.Net.HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AuthenticatorCodeInvalid", + ResourceSource = typeof(IdentityResources), + }; } await _userManager.SetTwoFactorEnabledAsync(user, true); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs index 71523b2f76..2c606b98b3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AdminConfirmEmail/AdminConfirmEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AdminConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AdminConfirmEmail; public sealed class AdminConfirmEmailCommandValidator : AbstractValidator { - public AdminConfirmEmailCommandValidator() + public AdminConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs index 703d43d398..500c4242b4 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs @@ -1,16 +1,18 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; public sealed class AssignUserRolesCommandValidator : AbstractValidator { - public AssignUserRolesCommandValidator() + public AssignUserRolesCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.UserRoles) - .NotNull().WithMessage("User roles list is required."); + .NotNull().WithMessage(_ => localizer["Validation.UserRolesRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs index 0581ae1878..6563029bb8 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs @@ -1,7 +1,9 @@ using FluentValidation; using FSH.Framework.Core.Context; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; @@ -12,26 +14,27 @@ public sealed class ChangePasswordValidator : AbstractValidator localizer) { _passwordHistoryService = passwordHistoryService; _currentUser = currentUser; RuleFor(p => p.Password) .NotEmpty() - .WithMessage("Current password is required."); + .WithMessage(_ => localizer["Validation.CurrentPasswordRequired"]); RuleFor(p => p.NewPassword) .NotEmpty() - .WithMessage("New password is required.") + .WithMessage(_ => localizer["Validation.NewPasswordRequired"]) .NotEqual(p => p.Password) - .WithMessage("New password must be different from the current password.") + .WithMessage(_ => localizer["Validation.NewPasswordMustDiffer"]) .MustAsync(NotBeInPasswordHistoryAsync) - .WithMessage("This password has been used recently. Please choose a different password."); + .WithMessage(_ => localizer["Validation.PasswordRecentlyUsed"]); RuleFor(p => p.ConfirmNewPassword) .Equal(p => p.NewPassword) - .WithMessage("Passwords do not match."); + .WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); } private async Task NotBeInPasswordHistoryAsync(string newPassword, CancellationToken cancellationToken) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs index 54805a491b..d8b599e9fe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs @@ -1,19 +1,21 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; public sealed class ConfirmEmailCommandValidator : AbstractValidator { - public ConfirmEmailCommandValidator() + public ConfirmEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); RuleFor(x => x.Code) - .NotEmpty().WithMessage("Confirmation code is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.ConfirmationCodeRequired"]); RuleFor(x => x.Tenant) - .NotEmpty().WithMessage("Tenant is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.TenantRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs index f5410d84ac..f73908b372 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; public sealed class DeleteUserCommandValidator : AbstractValidator { - public DeleteUserCommandValidator() + public DeleteUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Id) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs index a7d6704751..1d833cf798 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs @@ -2,6 +2,7 @@ using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.v1.Users.GetUserGroups; using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -25,7 +26,12 @@ public async ValueTask> Handle(GetUserGroupsQuery query, C if (!userExists) { - throw new NotFoundException($"User with ID '{query.UserId}' not found."); + throw new NotFoundException($"User with ID '{query.UserId}' not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [query.UserId], + ResourceSource = typeof(IdentityResources), + }; } // Get user's groups diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs index 54d774b2da..f52db83b99 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs @@ -1,39 +1,41 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; public sealed class RegisterUserCommandValidator : AbstractValidator { - public RegisterUserCommandValidator() + public RegisterUserCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.FirstName) - .NotEmpty().WithMessage("First name is required.") - .MaximumLength(100).WithMessage("First name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.FirstNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.FirstNameMaxLength"]); RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Last name is required.") - .MaximumLength(100).WithMessage("Last name must not exceed 100 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.LastNameRequired"]) + .MaximumLength(100).WithMessage(_ => localizer["Validation.LastNameMaxLength"]); RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required.") - .EmailAddress().WithMessage("A valid email address is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.EmailRequired"]) + .EmailAddress().WithMessage(_ => localizer["Validation.EmailInvalid"]); RuleFor(x => x.UserName) - .NotEmpty().WithMessage("Username is required.") - .MinimumLength(3).WithMessage("Username must be at least 3 characters.") - .MaximumLength(50).WithMessage("Username must not exceed 50 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.UsernameRequired"]) + .MinimumLength(3).WithMessage(_ => localizer["Validation.UsernameMinLength"]) + .MaximumLength(50).WithMessage(_ => localizer["Validation.UsernameMaxLength"]); RuleFor(x => x.Password) - .NotEmpty().WithMessage("Password is required.") - .MinimumLength(6).WithMessage("Password must be at least 6 characters."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordRequired"]) + .MinimumLength(6).WithMessage(_ => localizer["Validation.PasswordMinLength"]); RuleFor(x => x.ConfirmPassword) - .NotEmpty().WithMessage("Password confirmation is required.") - .Equal(x => x.Password).WithMessage("Passwords do not match."); + .NotEmpty().WithMessage(_ => localizer["Validation.PasswordConfirmationRequired"]) + .Equal(x => x.Password).WithMessage(_ => localizer["Validation.PasswordsDoNotMatch"]); RuleFor(x => x.PhoneNumber) - .MaximumLength(20).WithMessage("Phone number must not exceed 20 characters.") + .MaximumLength(20).WithMessage(_ => localizer["Validation.PhoneNumberMaxLength"]) .When(x => x.PhoneNumber is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs index 34d29f7b1b..28cd866e23 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResendConfirmationEmail/ResendConfirmationEmailCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ResendConfirmationEmail; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ResendConfirmationEmail; public sealed class ResendConfirmationEmailCommandValidator : AbstractValidator { - public ResendConfirmationEmailCommandValidator() + public ResendConfirmationEmailCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs index 7069e6f653..a5328bc3d5 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryValidator.cs @@ -1,14 +1,16 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.SearchUsers; public sealed class SearchUsersQueryValidator : AbstractValidator { - public SearchUsersQueryValidator() + public SearchUsersQueryValidator(IStringLocalizer localizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); RuleFor(q => q.Search) .MaximumLength(200) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs index 236b76e778..36a9fc6304 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SetProfileImage/SetProfileImageCommandHandler.cs @@ -18,7 +18,10 @@ public async ValueTask Handle(SetProfileImageCommand command, Cancellation var userId = currentUser.GetUserId(); if (userId == Guid.Empty) { - throw new UnauthorizedException("no current user"); + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; } await profileService diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs index 4eece88de2..2df3f36a54 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; public sealed class ToggleUserStatusCommandValidator : AbstractValidator { - public ToggleUserStatusCommandValidator() + public ToggleUserStatusCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(_ => localizer["Validation.UserIdRequired"]); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs index 2fa5ff12d3..cb60d2af57 100644 --- a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Identity.Claims; using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Localization; using System.Security.Claims; namespace FSH.Modules.Identity.Services; @@ -42,7 +43,11 @@ public void SetCurrentUser(ClaimsPrincipal user) { if (_user != null) { - throw new CustomException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization") + { + MessageKey = "Identity.InScopeInitializationOnly", + ResourceSource = typeof(IdentityResources), + }; } _user = user; @@ -52,7 +57,11 @@ public void SetCurrentUserId(string userId) { if (_userId != Guid.Empty) { - throw new CustomException("Method reserved for in-scope initialization"); + throw new CustomException("Method reserved for in-scope initialization") + { + MessageKey = "Identity.InScopeInitializationOnly", + ResourceSource = typeof(IdentityResources), + }; } if (!string.IsNullOrEmpty(userId)) diff --git a/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs b/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs index 6ed5ebef65..56cf011147 100644 --- a/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/ImpersonationGrantService.cs @@ -4,6 +4,7 @@ using FSH.Modules.Identity.Contracts.v1.Impersonation; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; @@ -82,7 +83,11 @@ public async Task RevokeAsync( var grant = await db.ImpersonationGrants .FirstOrDefaultAsync(g => g.Id == id, ct) .ConfigureAwait(false) - ?? throw new NotFoundException("impersonation grant not found"); + ?? throw new NotFoundException("impersonation grant not found") + { + MessageKey = "Identity.ImpersonationGrantNotFound", + ResourceSource = typeof(IdentityResources), + }; if (grant.IsTerminal) { diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs index 1f41f867a6..e194d9b5ca 100644 --- a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs @@ -6,6 +6,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using UAParser; @@ -40,7 +41,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(_multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("Invalid tenant"); + throw new UnauthorizedException("Invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -88,7 +92,11 @@ public async Task> GetUserSessionsAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot view sessions for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot view sessions for another user") + { + MessageKey = "Identity.CannotViewOthersSessions", + ResourceSource = typeof(IdentityResources), + }; } var now = _timeProvider.GetUtcNow().UtcDateTime; @@ -196,7 +204,11 @@ public async Task RevokeSessionAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(session.UserId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot revoke session for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot revoke session for another user") + { + MessageKey = "Identity.CannotRevokeOthersSession", + ResourceSource = typeof(IdentityResources), + }; } var tenantId = _multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; @@ -224,7 +236,11 @@ public async Task RevokeAllSessionsAsync( var currentUserId = _currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { - throw new UnauthorizedAccessException("Cannot revoke sessions for another user"); + throw new LocalizedUnauthorizedAccessException("Cannot revoke sessions for another user") + { + MessageKey = "Identity.CannotRevokeOthersSessions", + ResourceSource = typeof(IdentityResources), + }; } var query = _db.UserSessions diff --git a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs index f29a3eb8fd..b0bfbb5795 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs @@ -7,6 +7,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using System.Collections.ObjectModel; @@ -66,7 +67,11 @@ public async Task ResetPasswordAsync(string email, string password, string token var user = await userManager.FindByEmailAsync(email); if (user == null) { - throw new NotFoundException("user not found"); + throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; } token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); @@ -75,7 +80,11 @@ public async Task ResetPasswordAsync(string email, string password, string token if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new CustomException("error resetting password", errors); + throw new CustomException("error resetting password", errors) + { + MessageKey = "Identity.ErrorResettingPassword", + ResourceSource = typeof(IdentityResources), + }; } // Raise domain event for password reset @@ -88,14 +97,22 @@ public async Task ChangePasswordAsync(string password, string newPassword, strin { var user = await userManager.FindByIdAsync(userId); - _ = user ?? throw new NotFoundException("user not found"); + _ = user ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; var result = await userManager.ChangePasswordAsync(user, password, newPassword); if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - throw new CustomException("failed to change password", errors); + throw new CustomException("failed to change password", errors) + { + MessageKey = "Identity.FailedToChangePassword", + ResourceSource = typeof(IdentityResources), + }; } // Raise domain event for password change @@ -114,7 +131,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 79409e4379..ad2dff8911 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -11,6 +11,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; @@ -79,14 +80,23 @@ public async Task ConfirmEmailAsync(string userId, string code, string t .Where(u => u.Id == userId && !u.EmailConfirmed) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new CustomException("An error occurred while confirming E-Mail."); + _ = user ?? throw new CustomException("An error occurred while confirming E-Mail.") + { + MessageKey = "Identity.EmailConfirmationError", + ResourceSource = typeof(IdentityResources), + }; code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); var result = await userManager.ConfirmEmailAsync(user, code); return result.Succeeded ? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)); + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)) + { + MessageKey = "Identity.EmailConfirmationFailedFor", + MessageArgs = [user.Email!], + ResourceSource = typeof(IdentityResources), + }; } public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancellationToken = default) @@ -96,7 +106,12 @@ public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancel var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException($"User {userId} was not found."); + ?? throw new NotFoundException($"User {userId} was not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; // Idempotent: a second confirm is a no-op rather than an error. if (user.EmailConfirmed) @@ -112,7 +127,12 @@ public async Task AdminConfirmEmailAsync(string userId, CancellationToken cancel CultureInfo.InvariantCulture, "An error occurred while confirming the email for {0}: {1}", user.Email, - string.Join("; ", result.Errors.Select(e => e.Description)))); + string.Join("; ", result.Errors.Select(e => e.Description)))) + { + MessageKey = "Identity.EmailConfirmationFailedWithErrors", + MessageArgs = [user.Email!, string.Join("; ", result.Errors.Select(e => e.Description))], + ResourceSource = typeof(IdentityResources), + }; } } @@ -123,14 +143,24 @@ public async Task ResendConfirmationEmailAsync(string userId, string origin, Can var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException($"User {userId} was not found."); + ?? throw new NotFoundException($"User {userId} was not found.") + { + MessageKey = "Identity.UserNotFoundById", + MessageArgs = [userId], + ResourceSource = typeof(IdentityResources), + }; if (user.EmailConfirmed) { throw new CustomException(string.Format( CultureInfo.InvariantCulture, "The email for {0} is already confirmed.", - user.Email)); + user.Email)) + { + MessageKey = "Identity.EmailAlreadyConfirmed", + MessageArgs = [user.Email!], + ResourceSource = typeof(IdentityResources), + }; } await SendConfirmationEmailAsync(user, origin, cancellationToken); @@ -144,21 +174,33 @@ public async Task ConfirmPhoneNumberAsync(string userId, string code, Ca .Where(u => u.Id == userId && !u.PhoneNumberConfirmed) .FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new CustomException("An error occurred while confirming phone number."); + _ = user ?? throw new CustomException("An error occurred while confirming phone number.") + { + MessageKey = "Identity.PhoneConfirmationError", + ResourceSource = typeof(IdentityResources), + }; code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code); return result.Succeeded ? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)); + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)) + { + MessageKey = "Identity.PhoneConfirmationFailedFor", + MessageArgs = [user.PhoneNumber!], + ResourceSource = typeof(IdentityResources), + }; } private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -166,7 +208,11 @@ private static string ExtractEmailFromPrincipal(ClaimsPrincipal principal) { return principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue("email") - ?? throw new CustomException("Email claim is required for external authentication."); + ?? throw new CustomException("Email claim is required for external authentication.") + { + MessageKey = "Identity.EmailClaimRequired", + ResourceSource = typeof(IdentityResources), + }; } private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal principal, string email) @@ -193,7 +239,11 @@ private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal princip throw new CustomException( "Failed to create user from external principal.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.FailedToCreateUserFromPrincipal", + ResourceSource = typeof(IdentityResources), + }; } return user; @@ -233,7 +283,11 @@ private static void ValidatePasswordMatch(string password, string confirmPasswor throw new CustomException( "Passwords do not match.", errors: null, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.PasswordsDoNotMatch", + ResourceSource = typeof(IdentityResources), + }; } } @@ -267,7 +321,11 @@ private async Task CreateUserWithPasswordAsync( throw new CustomException( "Unable to register the user.", errors, - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.UnableToRegisterUser", + ResourceSource = typeof(IdentityResources), + }; } return user; diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs index 52f55a60a3..4cab8f3255 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs @@ -8,6 +8,7 @@ using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -26,7 +27,11 @@ public async Task AssignRolesAsync(string userId, List user var user = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; await ValidateAdminRoleChangeAsync(user, userRoles); @@ -44,10 +49,18 @@ public async Task AssignRolesAsync(string userId, List user public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found"); + ?? throw new NotFoundException("user not found") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) - ?? throw new NotFoundException("roles not found"); + ?? throw new NotFoundException("roles not found") + { + MessageKey = "Identity.RolesNotFound", + ResourceSource = typeof(IdentityResources), + }; // Single membership query instead of one IsInRoleAsync round-trip per role. var memberships = await userManager.GetRolesAsync(user); @@ -90,13 +103,21 @@ private async Task ValidateAdminRoleChangeAsync(FshUser user, List throw new CustomException( "Administrators cannot remove their own admin role.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AdminCannotRemoveOwnRole", + ResourceSource = typeof(IdentityResources), + }; } // The root tenant's seed admin is the framework's last-resort recovery account. if (IsRootTenantAdmin(user)) { - throw new ForbiddenException("The root tenant administrator cannot be demoted."); + throw new ForbiddenException("The root tenant administrator cannot be demoted.") + { + MessageKey = "Identity.RootAdminCannotBeDemoted", + ResourceSource = typeof(IdentityResources), + }; } // After this removal, at least one admin must remain in the tenant — matches @@ -118,7 +139,11 @@ private async Task EnsureMinimumAdminCountAsync() throw new CustomException( "Tenant must retain at least one administrator.", Array.Empty(), - HttpStatusCode.BadRequest); + HttpStatusCode.BadRequest) + { + MessageKey = "Identity.TenantMustRetainOneAdmin", + ResourceSource = typeof(IdentityResources), + }; } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs index f0e12c4829..28666a248f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs @@ -7,6 +7,7 @@ using FSH.Modules.Auditing.Contracts; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using FSH.Modules.Identity.Localization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -40,7 +41,10 @@ private void EnsureValidTenant() { if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { - throw new UnauthorizedException("invalid tenant"); + throw new UnauthorizedException("invalid tenant") + { + MessageKey = "Error.InvalidTenant", + }; } } @@ -52,16 +56,26 @@ private async Task BuildToggleContextAsync( var actorId = currentUser.GetUserId(); if (actorId == Guid.Empty) { - throw new UnauthorizedException("authenticated user required to toggle status"); + throw new UnauthorizedException("authenticated user required to toggle status") + { + MessageKey = "Error.NoCurrentUser", + }; } var actor = await userManager.FindByIdAsync(actorId.ToString()) - ?? throw new UnauthorizedException("current user not found"); + ?? throw new UnauthorizedException("current user not found") + { + MessageKey = "Error.NoCurrentUser", + }; var targetUser = await userManager.Users .Where(u => u.Id == userId) .FirstOrDefaultAsync(cancellationToken) - ?? throw new NotFoundException("User Not Found."); + ?? throw new NotFoundException("User Not Found.") + { + MessageKey = "Identity.UserNotFound", + ResourceSource = typeof(IdentityResources), + }; return new ToggleStatusContext( ActorId: actorId, @@ -78,19 +92,31 @@ private async Task ValidateTogglePermissionsAsync( if (!await userManager.IsInRoleAsync(context.Actor, RoleConstants.Admin)) { await AuditPolicyFailureAsync(context, "ActorNotAdmin", cancellationToken); - throw new ForbiddenException("Only administrators can change user status."); + throw new ForbiddenException("Only administrators can change user status.") + { + MessageKey = "Identity.OnlyAdminsCanChangeStatus", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser && context.ActorId.ToString() == context.TargetUser.Id) { await AuditPolicyFailureAsync(context, "SelfDeactivationBlocked", cancellationToken); - throw new CustomException("Users cannot deactivate themselves.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Users cannot deactivate themselves.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.CannotDeactivateSelf", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser && await userManager.IsInRoleAsync(context.TargetUser, RoleConstants.Admin)) { await AuditPolicyFailureAsync(context, "AdminDeactivationBlocked", cancellationToken); - throw new CustomException("Administrators cannot be deactivated.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Administrators cannot be deactivated.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.AdminsCannotBeDeactivated", + ResourceSource = typeof(IdentityResources), + }; } if (!context.ActivateUser) @@ -107,7 +133,11 @@ private async Task EnsureMinimumActiveAdminsAsync( if (!activeAdmins.Any(u => u.IsActive)) { await AuditPolicyFailureAsync(context, "NoActiveAdmins", cancellationToken); - throw new CustomException("Tenant must have at least one active administrator.", Array.Empty(), HttpStatusCode.BadRequest); + throw new CustomException("Tenant must have at least one active administrator.", Array.Empty(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.TenantMustHaveActiveAdmin", + ResourceSource = typeof(IdentityResources), + }; } } @@ -130,7 +160,11 @@ private async Task SaveAndAuditAsync( var result = await userManager.UpdateAsync(context.TargetUser); if (!result.Succeeded) { - throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList(), HttpStatusCode.BadRequest); + throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList(), HttpStatusCode.BadRequest) + { + MessageKey = "Identity.ToggleStatusFailed", + ResourceSource = typeof(IdentityResources), + }; } await auditClient.WriteActivityAsync( diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs index cf7d7a5282..13d4bad51a 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs @@ -1,12 +1,14 @@ using FluentValidation; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts.v1.AdjustTenantValidity; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.AdjustTenantValidity; public sealed class AdjustTenantValidityCommandValidator : AbstractValidator { - public AdjustTenantValidityCommandValidator() + public AdjustTenantValidityCommandValidator(IStringLocalizer localizer) { RuleFor(t => t.TenantId).NotEmpty(); @@ -14,10 +16,10 @@ public AdjustTenantValidityCommandValidator() // Activate/Deactivate guards that already refuse the root tenant). RuleFor(t => t.TenantId) .Must(id => !string.Equals(id, MultitenancyConstants.Root.Id, StringComparison.Ordinal)) - .WithMessage("The root operator tenant's validity cannot be adjusted."); + .WithMessage(_ => localizer["Validation.RootTenantValidityImmutable"]); RuleFor(t => t.ValidUpto) .Must(d => d != default) - .WithMessage("A valid 'validUpto' date is required."); + .WithMessage(_ => localizer["Validation.ValidUptoRequired"]); } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs index 2e199a329d..ba57a6f7b7 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/CreateTenant/CreateTenantCommandValidator.cs @@ -2,26 +2,28 @@ using FSH.Framework.Persistence; using FSH.Modules.Multitenancy.Contracts; using FSH.Modules.Multitenancy.Contracts.v1.CreateTenant; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.CreateTenant; public sealed class CreateTenantCommandValidator : AbstractValidator { - public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator) + public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStringValidator connectionStringValidator, IStringLocalizer localizer) { RuleFor(t => t.Id).Cascade(CascadeMode.Stop) .NotEmpty() .MustAsync(async (id, ct) => !await tenantService.ExistsWithIdAsync(id, ct).ConfigureAwait(false)) - .WithMessage((_, id) => $"Tenant {id} already exists."); + .WithMessage((_, id) => localizer["Validation.TenantAlreadyExists", id]); RuleFor(t => t.Name).Cascade(CascadeMode.Stop) .NotEmpty() .MustAsync(async (name, ct) => !await tenantService.ExistsWithNameAsync(name!, ct).ConfigureAwait(false)) - .WithMessage((_, name) => $"Tenant {name} already exists."); + .WithMessage((_, name) => localizer["Validation.TenantAlreadyExists", name!]); RuleFor(t => t.ConnectionString).Cascade(CascadeMode.Stop) .Must((_, cs) => string.IsNullOrWhiteSpace(cs) || connectionStringValidator.TryValidate(cs)) - .WithMessage("Connection string invalid."); + .WithMessage(_ => localizer["Validation.ConnectionStringInvalid"]); RuleFor(t => t.AdminEmail).Cascade(CascadeMode.Stop) .NotEmpty() @@ -32,13 +34,13 @@ public CreateTenantCommandValidator(ITenantService tenantService, IConnectionStr RuleFor(t => t.AdminPassword).Cascade(CascadeMode.Stop) .NotEmpty() .MinimumLength(8) - .WithMessage("Admin password must be at least 8 characters."); + .WithMessage(_ => localizer["Validation.AdminPasswordMinLength"]); // Optional — null/empty falls back to the configured default plan. When supplied it must be a // lowercase plan slug; existence is validated by GetPlanTerm in the handler. RuleFor(t => t.PlanKey) .Matches("^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$") .When(t => !string.IsNullOrWhiteSpace(t.PlanKey)) - .WithMessage("Plan key must be a lowercase slug (a-z, 0-9, hyphen)."); + .WithMessage(_ => localizer["Validation.PlanKeySlug"]); } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs index 1e1d159d12..5d6a694d85 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenants/GetTenantsQueryValidator.cs @@ -1,13 +1,15 @@ using FluentValidation; +using FSH.Framework.Core.Localization; using FSH.Framework.Web.Validation; using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.GetTenants; public sealed class GetTenantsQueryValidator : AbstractValidator { - public GetTenantsQueryValidator() + public GetTenantsQueryValidator(IStringLocalizer localizer) { - Include(new PagedQueryValidator()); + Include(new PagedQueryValidator(localizer)); } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs index 3ccec43cca..a17367b3ef 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/RenewTenant/RenewTenantCommandValidator.cs @@ -1,17 +1,19 @@ using FluentValidation; using FSH.Modules.Multitenancy.Contracts.v1.RenewTenant; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Multitenancy.Features.v1.RenewTenant; public sealed class RenewTenantCommandValidator : AbstractValidator { - public RenewTenantCommandValidator() + public RenewTenantCommandValidator(IStringLocalizer localizer) { RuleFor(t => t.TenantId).NotEmpty(); RuleFor(t => t.PlanKey) .Matches("^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$") .When(t => !string.IsNullOrWhiteSpace(t.PlanKey)) - .WithMessage("Plan key must be a lowercase slug (a-z, 0-9, hyphen)."); + .WithMessage(_ => localizer["Validation.PlanKeySlug"]); } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs index 889d447f56..534dc74178 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/UpdateTenantTheme/UpdateTenantThemeCommandValidator.cs @@ -1,33 +1,35 @@ using FluentValidation; using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Contracts.v1.UpdateTenantTheme; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.Localization; using System.Text.RegularExpressions; namespace FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; public partial class UpdateTenantThemeCommandValidator : AbstractValidator { - public UpdateTenantThemeCommandValidator() + public UpdateTenantThemeCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Theme) .NotNull() - .WithMessage("Theme is required."); + .WithMessage(_ => localizer["Validation.ThemeRequired"]); RuleFor(x => x.Theme.LightPalette) .NotNull() - .SetValidator(new PaletteValidator()); + .SetValidator(new PaletteValidator(localizer)); RuleFor(x => x.Theme.DarkPalette) .NotNull() - .SetValidator(new PaletteValidator()); + .SetValidator(new PaletteValidator(localizer)); RuleFor(x => x.Theme.Typography) .NotNull() - .SetValidator(new TypographyValidator()); + .SetValidator(new TypographyValidator(localizer)); RuleFor(x => x.Theme.Layout) .NotNull() - .SetValidator(new LayoutValidator()); + .SetValidator(new LayoutValidator(localizer)); } [GeneratedRegex("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$")] @@ -35,17 +37,17 @@ public UpdateTenantThemeCommandValidator() private sealed class PaletteValidator : AbstractValidator { - public PaletteValidator() + public PaletteValidator(IStringLocalizer localizer) { - RuleFor(x => x.Primary).Must(BeValidHexColor).WithMessage("Primary must be a valid hex color."); - RuleFor(x => x.Secondary).Must(BeValidHexColor).WithMessage("Secondary must be a valid hex color."); - RuleFor(x => x.Tertiary).Must(BeValidHexColor).WithMessage("Tertiary must be a valid hex color."); - RuleFor(x => x.Background).Must(BeValidHexColor).WithMessage("Background must be a valid hex color."); - RuleFor(x => x.Surface).Must(BeValidHexColor).WithMessage("Surface must be a valid hex color."); - RuleFor(x => x.Error).Must(BeValidHexColor).WithMessage("Error must be a valid hex color."); - RuleFor(x => x.Warning).Must(BeValidHexColor).WithMessage("Warning must be a valid hex color."); - RuleFor(x => x.Success).Must(BeValidHexColor).WithMessage("Success must be a valid hex color."); - RuleFor(x => x.Info).Must(BeValidHexColor).WithMessage("Info must be a valid hex color."); + RuleFor(x => x.Primary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Primary"]); + RuleFor(x => x.Secondary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Secondary"]); + RuleFor(x => x.Tertiary).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Tertiary"]); + RuleFor(x => x.Background).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Background"]); + RuleFor(x => x.Surface).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Surface"]); + RuleFor(x => x.Error).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Error"]); + RuleFor(x => x.Warning).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Warning"]); + RuleFor(x => x.Success).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Success"]); + RuleFor(x => x.Info).Must(BeValidHexColor).WithMessage(_ => localizer["Validation.ColorMustBeHex", "Info"]); } private static bool BeValidHexColor(string color) => @@ -54,27 +56,27 @@ private static bool BeValidHexColor(string color) => private sealed class TypographyValidator : AbstractValidator { - public TypographyValidator() + public TypographyValidator(IStringLocalizer localizer) { RuleFor(x => x.FontFamily) .NotEmpty() .MaximumLength(200) .Must(BeValidFontFamily) - .WithMessage("FontFamily must be a valid web-safe font."); + .WithMessage(_ => localizer["Validation.FontFamilyWebSafe", "FontFamily"]); RuleFor(x => x.HeadingFontFamily) .NotEmpty() .MaximumLength(200) .Must(BeValidFontFamily) - .WithMessage("HeadingFontFamily must be a valid web-safe font."); + .WithMessage(_ => localizer["Validation.FontFamilyWebSafe", "HeadingFontFamily"]); RuleFor(x => x.FontSizeBase) .InclusiveBetween(10, 24) - .WithMessage("FontSizeBase must be between 10 and 24."); + .WithMessage(_ => localizer["Validation.FontSizeBaseRange"]); RuleFor(x => x.LineHeightBase) .InclusiveBetween(1.0, 2.5) - .WithMessage("LineHeightBase must be between 1.0 and 2.5."); + .WithMessage(_ => localizer["Validation.LineHeightBaseRange"]); } private static bool BeValidFontFamily(string fontFamily) => @@ -83,17 +85,17 @@ private static bool BeValidFontFamily(string fontFamily) => private sealed class LayoutValidator : AbstractValidator { - public LayoutValidator() + public LayoutValidator(IStringLocalizer localizer) { RuleFor(x => x.BorderRadius) .NotEmpty() .MaximumLength(20) .Matches(@"^\d+(px|rem|em|%)$") - .WithMessage("BorderRadius must be a valid CSS value (e.g., '4px', '0.5rem')."); + .WithMessage(_ => localizer["Validation.BorderRadiusInvalid"]); RuleFor(x => x.DefaultElevation) .InclusiveBetween(0, 24) - .WithMessage("DefaultElevation must be between 0 and 24."); + .WithMessage(_ => localizer["Validation.DefaultElevationRange"]); } } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs new file mode 100644 index 0000000000..74961366d1 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Multitenancy.Localization; + +/// Marker type binding IStringLocalizer<MultitenancyResources> to the Multitenancy resx catalog. +public sealed class MultitenancyResources; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx new file mode 100644 index 0000000000..25df319c32 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Este tenant foi desativado. Entre em contato com seu administrador. + + + A assinatura deste tenant expirou. Renove para continuar. + + + Tenant {0} não encontrado durante o provisionamento. + + + Tenant {0} não encontrado para provisionamento. + + + O provisionamento já está em execução para o tenant {0}. + + + Provisionamento não encontrado para o tenant {0}. + + + O tenant {0} não está provisionado. Status: {1}. + + + Provisionamento {0} para o tenant {1} não encontrado. + + + o tenant {0} já está ativado + + + o tenant {0} já está desativado + + + É necessário pelo menos um tenant ativo. + + + O tenant raiz não pode ser desativado. + + + AppTenantInfo {0} não encontrado. + + + Apenas o tenant raiz pode definir o tema padrão. + + + Tema do tenant {0} não encontrado. + + + A validade do tenant operador raiz não pode ser ajustada. + + + Uma data 'validUpto' válida é obrigatória. + + + O tenant {0} já existe. + + + String de conexão inválida. + + + A senha do administrador deve ter pelo menos 8 caracteres. + + + A chave do plano deve ser um slug minúsculo (a-z, 0-9, hífen). + + + O tema é obrigatório. + + + {0} deve ser uma cor hexadecimal válida. + + + {0} deve ser uma fonte web-safe válida. + + + FontSizeBase deve estar entre 10 e 24. + + + LineHeightBase deve estar entre 1.0 e 2.5. + + + BorderRadius deve ser um valor CSS válido (ex.: '4px', '0.5rem'). + + + DefaultElevation deve estar entre 0 e 24. + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx new file mode 100644 index 0000000000..fc4cf7d320 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + This tenant has been deactivated. Contact your administrator. + + + This tenant's subscription has expired. Please renew to continue. + + + Tenant {0} not found during provisioning. + + + Tenant {0} not found for provisioning. + + + Provisioning already running for tenant {0}. + + + Provisioning not found for tenant {0}. + + + Tenant {0} is not provisioned. Status: {1}. + + + Provisioning {0} for tenant {1} not found. + + + tenant {0} is already activated + + + tenant {0} is already deactivated + + + At least one active tenant is required. + + + The root tenant cannot be deactivated. + + + AppTenantInfo {0} Not Found. + + + Only the root tenant can set the default theme + + + Theme for tenant {0} not found + + + The root operator tenant's validity cannot be adjusted. + + + A valid 'validUpto' date is required. + + + Tenant {0} already exists. + + + Connection string invalid. + + + Admin password must be at least 8 characters. + + + Plan key must be a lowercase slug (a-z, 0-9, hyphen). + + + Theme is required. + + + {0} must be a valid hex color. + + + {0} must be a valid web-safe font. + + + FontSizeBase must be between 10 and 24. + + + LineHeightBase must be between 1.0 and 2.5. + + + BorderRadius must be a valid CSS value (e.g., '4px', '0.5rem'). + + + DefaultElevation must be between 0 and 24. + + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj index eb11887cb2..34ba1b60c0 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj @@ -2,7 +2,8 @@ FSH.Modules.Multitenancy FSH.Modules.Multitenancy - $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172 + + $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172;S2094 diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs index 698a90277c..f79a604756 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/MultitenancyModule.cs @@ -26,6 +26,7 @@ using FSH.Modules.Multitenancy.Features.v1.TenantProvisioning.RetryTenantProvisioning; using FSH.Modules.Multitenancy.Features.v1.RenewTenant; using FSH.Modules.Multitenancy.Features.v1.UpdateTenantTheme; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Provisioning; using FSH.Modules.Multitenancy.Services; using Hangfire; @@ -180,7 +181,11 @@ public void ConfigureMiddleware(IApplicationBuilder app) { if (!tenant.IsActive) { - throw new ForbiddenException("This tenant has been deactivated. Contact your administrator."); + throw new ForbiddenException("This tenant has been deactivated. Contact your administrator.") + { + MessageKey = "Multitenancy.TenantDeactivated", + ResourceSource = typeof(MultitenancyResources), + }; } // Expiry is enforced on every request (not just at login) with a grace period: @@ -191,7 +196,11 @@ public void ConfigureMiddleware(IApplicationBuilder app) var graceEndsUtc = tenant.ValidUpto.AddDays(graceDays); if (nowUtc > graceEndsUtc) { - throw new ForbiddenException("This tenant's subscription has expired. Please renew to continue."); + throw new ForbiddenException("This tenant's subscription has expired. Please renew to continue.") + { + MessageKey = "Multitenancy.TenantSubscriptionExpired", + ResourceSource = typeof(MultitenancyResources), + }; } // Inside the grace period: surface days-left so clients can warn. Set via OnStarting so diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs index 221125a3c4..0239b67cd5 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs @@ -4,6 +4,7 @@ using FSH.Framework.Persistence; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Services; using Microsoft.Extensions.Logging; @@ -34,7 +35,12 @@ public TenantProvisioningJob( public async Task RunAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default) { var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) - ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning."); + ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning.") + { + MessageKey = "Multitenancy.TenantNotFoundDuringProvisioning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; var currentStep = TenantProvisioningStepName.Database; try diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs index c7a9ec719a..7a4ee5d9ea 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs @@ -4,6 +4,7 @@ using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Data; +using FSH.Modules.Multitenancy.Localization; using Hangfire; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -36,12 +37,22 @@ public TenantProvisioningService( public async Task StartAsync(string tenantId, CancellationToken cancellationToken) { var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) - ?? throw new NotFoundException($"Tenant {tenantId} not found for provisioning."); + ?? throw new NotFoundException($"Tenant {tenantId} not found for provisioning.") + { + MessageKey = "Multitenancy.TenantNotFoundForProvisioning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; var existing = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); if (existing is not null && (existing.Status is TenantProvisioningStatus.Running or TenantProvisioningStatus.Pending)) { - throw new CustomException($"Provisioning already running for tenant {tenantId}."); + throw new CustomException($"Provisioning already running for tenant {tenantId}.") + { + MessageKey = "Multitenancy.ProvisioningAlreadyRunning", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } var correlationId = Guid.NewGuid().ToString(); @@ -85,7 +96,12 @@ public async Task StartAsync(string tenantId, CancellationTo public async Task GetStatusAsync(string tenantId, CancellationToken cancellationToken) { var provisioning = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false) - ?? throw new NotFoundException($"Provisioning not found for tenant {tenantId}."); + ?? throw new NotFoundException($"Provisioning not found for tenant {tenantId}.") + { + MessageKey = "Multitenancy.ProvisioningNotFound", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; return ToDto(provisioning); } @@ -100,7 +116,12 @@ public async Task EnsureCanActivateAsync(string tenantId, CancellationToken canc if (provisioning.Status != TenantProvisioningStatus.Completed) { - throw new CustomException($"Tenant {tenantId} is not provisioned. Status: {provisioning.Status}."); + throw new CustomException($"Tenant {tenantId} is not provisioned. Status: {provisioning.Status}.") + { + MessageKey = "Multitenancy.TenantNotProvisioned", + MessageArgs = [tenantId, provisioning.Status], + ResourceSource = typeof(MultitenancyResources), + }; } } @@ -171,7 +192,12 @@ private async Task RequireAsync(string tenantId, string corr .Include(p => p.Steps) .FirstOrDefaultAsync(p => p.TenantId == tenantId && p.CorrelationId == correlationId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Provisioning {correlationId} for tenant {tenantId} not found."); + ?? throw new NotFoundException($"Provisioning {correlationId} for tenant {tenantId} not found.") + { + MessageKey = "Multitenancy.ProvisioningCorrelationNotFound", + MessageArgs = [correlationId, tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } private static bool TryEnsureJobStorage() diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs index 15d8fdbe01..701b5c135d 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs @@ -9,6 +9,7 @@ using FSH.Modules.Multitenancy.Contracts.v1.GetTenants; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Features.v1.GetTenants; +using FSH.Modules.Multitenancy.Localization; using FSH.Modules.Multitenancy.Provisioning; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -56,7 +57,12 @@ public async Task ActivateAsync(string id, CancellationToken cancellatio if (tenant.IsActive) { - throw new CustomException($"tenant {id} is already activated"); + throw new CustomException($"tenant {id} is already activated") + { + MessageKey = "Multitenancy.TenantAlreadyActivated", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; } await _provisioningService.EnsureCanActivateAsync(id, cancellationToken).ConfigureAwait(false); @@ -123,18 +129,31 @@ public async Task DeactivateAsync(string id, CancellationToken cancellat var tenant = await GetTenantInfoAsync(id, cancellationToken).ConfigureAwait(false); if (!tenant.IsActive) { - throw new CustomException($"tenant {id} is already deactivated"); + throw new CustomException($"tenant {id} is already deactivated") + { + MessageKey = "Multitenancy.TenantAlreadyDeactivated", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; } int tenantCount = (await _tenantStore.GetAllAsync().ConfigureAwait(false)).Count(t => t.IsActive); if (tenantCount <= 1) { - throw new CustomException("At least one active tenant is required."); + throw new CustomException("At least one active tenant is required.") + { + MessageKey = "Multitenancy.AtLeastOneActiveTenantRequired", + ResourceSource = typeof(MultitenancyResources), + }; } if (tenant.Id.Equals(MultitenancyConstants.Root.Id, StringComparison.OrdinalIgnoreCase)) { - throw new CustomException("The root tenant cannot be deactivated."); + throw new CustomException("The root tenant cannot be deactivated.") + { + MessageKey = "Multitenancy.RootTenantCannotBeDeactivated", + ResourceSource = typeof(MultitenancyResources), + }; } tenant.Deactivate(); @@ -247,7 +266,12 @@ public async Task AdjustValidityAsync(string id, DateTime validUpto, C private async Task GetTenantInfoAsync(string id, CancellationToken cancellationToken = default) => await _tenantStore.GetAsync(id).ConfigureAwait(false) - ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found."); + ?? throw new NotFoundException($"{typeof(AppTenantInfo).Name} {id} Not Found.") + { + MessageKey = "Multitenancy.TenantNotFound", + MessageArgs = [id], + ResourceSource = typeof(MultitenancyResources), + }; // Finbuckle resolves via the distributed-cache store first (60-min TTL) while the injected store only // writes EF, so push the new state into the cache store too — otherwise flips lag until cache expiry. diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs index e21fc9e4fe..16f94ea406 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs @@ -10,6 +10,7 @@ using FSH.Modules.Multitenancy.Contracts.Dtos; using FSH.Modules.Multitenancy.Data; using FSH.Modules.Multitenancy.Domain; +using FSH.Modules.Multitenancy.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; @@ -220,7 +221,11 @@ public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = var currentTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id; if (currentTenantId != MultitenancyConstants.Root.Id) { - throw new ForbiddenException("Only the root tenant can set the default theme"); + throw new ForbiddenException("Only the root tenant can set the default theme") + { + MessageKey = "Multitenancy.OnlyRootCanSetDefaultTheme", + ResourceSource = typeof(MultitenancyResources), + }; } // Clear existing default @@ -240,7 +245,12 @@ public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = if (entity is null) { - throw new NotFoundException($"Theme for tenant {tenantId} not found"); + throw new NotFoundException($"Theme for tenant {tenantId} not found") + { + MessageKey = "Multitenancy.TenantThemeNotFound", + MessageArgs = [tenantId], + ResourceSource = typeof(MultitenancyResources), + }; } entity.IsDefault = true; diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs index 4f46f535b0..8d7676c23a 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/GetUnreadCount/GetUnreadCountQueryHandler.cs @@ -16,7 +16,13 @@ public async ValueTask Handle(GetUnreadCountQuery query, CancellationToken { ArgumentNullException.ThrowIfNull(query); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); return await db.Notifications.AsNoTracking() diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs index cf3f1968e3..d5458aac42 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/ListNotifications/ListNotificationsQueryHandler.cs @@ -19,7 +19,13 @@ public async ValueTask> Handle(ListNotificat { ArgumentNullException.ThrowIfNull(q); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); int page = Math.Max(1, q.Page); diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs index d565f1e73f..2d8f3d83ab 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkAllNotificationsRead/MarkAllNotificationsReadCommandHandler.cs @@ -16,7 +16,13 @@ public async ValueTask Handle(MarkAllNotificationsReadCommand cmd, Cancella { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); var now = DateTime.UtcNow; diff --git a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs index 0fb8ead556..ea73ece82a 100644 --- a/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs +++ b/src/Modules/Notifications/Modules.Notifications/Features/v1/MarkNotificationRead/MarkNotificationReadCommandHandler.cs @@ -2,6 +2,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Notifications.Contracts.v1.Commands; using FSH.Modules.Notifications.Data; +using FSH.Modules.Notifications.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,13 @@ public async ValueTask Handle(MarkNotificationReadCommand cmd, Cancellatio { ArgumentNullException.ThrowIfNull(cmd); var userId = currentUser.GetUserId(); - if (userId == Guid.Empty) throw new UnauthorizedException("no current user"); + if (userId == Guid.Empty) + { + throw new UnauthorizedException("no current user") + { + MessageKey = "Error.NoCurrentUser", + }; + } var currentUserId = userId.ToString(); // Caller-scoped: filter by (Id, UserId) so users can only mutate their own rows. Returns @@ -24,7 +31,11 @@ public async ValueTask Handle(MarkNotificationReadCommand cmd, Cancellatio var notification = await db.Notifications .FirstOrDefaultAsync(n => n.Id == cmd.NotificationId && n.UserId == currentUserId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException("Notification not found."); + ?? throw new NotFoundException("Notification not found.") + { + MessageKey = "Notifications.NotificationNotFound", + ResourceSource = typeof(NotificationsResources), + }; notification.MarkRead(); await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs index c2ff2d7d3c..d53586b4cf 100644 --- a/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs +++ b/src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/BillingEmailBodies.cs @@ -1,3 +1,4 @@ +// TODO(i18n): email bodies are localized in a follow-up PR — recipient locale must be propagated (no HTTP request culture in background handlers). using System.Globalization; namespace FSH.Modules.Notifications.IntegrationEventHandlers; diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs new file mode 100644 index 0000000000..e67084865a --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Notifications.Localization; + +/// Marker type binding IStringLocalizer<NotificationsResources> to the Notifications resx catalog. +public sealed class NotificationsResources; diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx new file mode 100644 index 0000000000..4d02b7864a --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.pt-BR.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Notificação não encontrada. + + diff --git a/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx new file mode 100644 index 0000000000..6db9a0e6e8 --- /dev/null +++ b/src/Modules/Notifications/Modules.Notifications/Localization/NotificationsResources.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Notification not found. + + diff --git a/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj b/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj index e97c3074bb..77ee76efc8 100644 --- a/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj +++ b/src/Modules/Notifications/Modules.Notifications/Modules.Notifications.csproj @@ -3,7 +3,9 @@ FSH.Modules.Notifications FSH.Modules.Notifications - $(NoWarn);CA1031;CA1711;CA1812;CA1859;CA1002;CA2227;S3267 + + + $(NoWarn);CA1031;CA1711;CA1812;CA1859;CA1002;CA2227;S3267;S2094;S1135 diff --git a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs index 939d27256e..b932b9dedf 100644 --- a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs +++ b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; using FSH.Modules.Tickets.Domain.Events; +using FSH.Modules.Tickets.Localization; namespace FSH.Modules.Tickets.Domain; @@ -120,7 +121,11 @@ public void Resolve(string? resolutionNote) throw new CustomException( "A closed ticket cannot be resolved — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotResolve", + ResourceSource = typeof(TicketsResources), + }; } if (Status == TicketStatus.Resolved) { @@ -148,7 +153,12 @@ public void Close() throw new CustomException( $"Only a resolved ticket can be closed — current status is {Status}. Resolve it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.OnlyResolvedCanClose", + MessageArgs = [Status], + ResourceSource = typeof(TicketsResources), + }; } ClosedAtUtc = DateTime.UtcNow; @@ -168,7 +178,11 @@ public void UpdateDetails(string title, string? description, TicketPriority prio throw new CustomException( "A closed ticket cannot be edited — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotEdit", + ResourceSource = typeof(TicketsResources), + }; } Title = title.Trim(); @@ -201,7 +215,11 @@ public Guid AddComment(Guid authorUserId, string body) throw new CustomException( "A closed ticket cannot accept new comments — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.ClosedCannotComment", + ResourceSource = typeof(TicketsResources), + }; } var comment = TicketComment.Create(Id, authorUserId, body); @@ -234,7 +252,12 @@ private void ThrowIfClosedOrResolved(string action) throw new CustomException( $"Cannot {action} a ticket in status {Status} — reopen it first.", (IEnumerable?)null, - HttpStatusCode.Conflict); + HttpStatusCode.Conflict) + { + MessageKey = "Tickets.CannotActionInStatus", + MessageArgs = [action, Status], + ResourceSource = typeof(TicketsResources), + }; } } } diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs index 45189fae0a..bc206ff847 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AddTicketComment/AddTicketCommentCommandHandler.cs @@ -3,6 +3,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; +using FSH.Modules.Tickets.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,11 @@ public async ValueTask Handle(AddTicketCommentCommand command, Cancellatio throw new CustomException( "Cannot post a comment without an authenticated author.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Tickets.CommentAuthorRequired", + ResourceSource = typeof(TicketsResources), + }; } // Load the Comments collection up front so EF's change tracker detects the new TicketComment @@ -32,7 +37,12 @@ public async ValueTask Handle(AddTicketCommentCommand command, Cancellatio .Include(t => t.Comments) .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; var commentId = ticket.AddComment(authorId, command.Body); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs index 6c106b4126..b10b921d28 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/AssignTicket/AssignTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(AssignTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Assign(command.AssigneeUserId); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs index eb58248a2f..df5ed4b12b 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CloseTicket/CloseTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(CloseTicketCommand command, CancellationToke var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Close(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs index 41dda7cd16..735027b7a6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/CreateTicket/CreateTicketCommandHandler.cs @@ -5,6 +5,7 @@ using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; +using FSH.Modules.Tickets.Localization; using Mediator; using FSH.Framework.Persistence; using Microsoft.EntityFrameworkCore; @@ -26,7 +27,11 @@ public async ValueTask Handle(CreateTicketCommand command, CancellationTok throw new CustomException( "Cannot create a ticket without an authenticated reporter.", (IEnumerable?)null, - HttpStatusCode.Unauthorized); + HttpStatusCode.Unauthorized) + { + MessageKey = "Tickets.ReporterRequired", + ResourceSource = typeof(TicketsResources), + }; } // Sequential, tenant-scoped ticket numbers (TK-1, …). Count ALL rows incl. soft-deleted so a diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs index ff513feb02..32bc9a1013 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/DeleteTicket/DeleteTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; // Soft delete: the audit interceptor converts the EF Delete into an IsDeleted flip. // Comments are not auto-included, so they are left untouched and survive a Restore. diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs index fd5e439bae..06dc641b70 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/GetTicketById/GetTicketByIdQueryHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -22,7 +23,12 @@ public async ValueTask Handle(GetTicketByIdQuery query, CancellationT if (ticket is null) { - throw new NotFoundException($"Ticket {query.TicketId} not found."); + throw new NotFoundException($"Ticket {query.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [query.TicketId], + ResourceSource = typeof(TicketsResources), + }; } int commentCount = await dbContext.TicketComments diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs index cc3effd3c4..9a865168e6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ListTicketComments/ListTicketCommentsQueryHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.Dtos; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using FSH.Modules.Tickets.Domain; @@ -25,7 +26,12 @@ public async ValueTask> Handle( .ConfigureAwait(false); if (!ticketExists) { - throw new NotFoundException($"Ticket {query.TicketId} not found."); + throw new NotFoundException($"Ticket {query.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [query.TicketId], + ResourceSource = typeof(TicketsResources), + }; } var comments = await dbContext.TicketComments diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs index c327bdb0a2..7c17450ed9 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ReopenTicket/ReopenTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ReopenTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Reopen(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs index 136f15734e..ea57e17ff6 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/ResolveTicket/ResolveTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(ResolveTicketCommand command, CancellationTo var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Resolve(command.ResolutionNote); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs index e6faf03b04..385312559a 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/RestoreTicket/RestoreTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Persistence; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Contracts.v1.Tickets; using FSH.Modules.Tickets.Data; using Mediator; @@ -18,7 +19,12 @@ public async ValueTask Handle(RestoreTicketCommand command, CancellationTo .IgnoreQueryFilters([QueryFilters.SoftDelete]) .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.Restore(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs index 72812e96df..b6378e2337 100644 --- a/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs +++ b/src/Modules/Tickets/Modules.Tickets/Features/v1/Tickets/UpdateTicket/UpdateTicketCommandHandler.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Tickets.Contracts.v1.Tickets; +using FSH.Modules.Tickets.Localization; using FSH.Modules.Tickets.Data; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(UpdateTicketCommand command, CancellationTok var ticket = await dbContext.Tickets .FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Ticket {command.TicketId} not found."); + ?? throw new NotFoundException($"Ticket {command.TicketId} not found.") + { + MessageKey = "Tickets.TicketNotFound", + MessageArgs = [command.TicketId], + ResourceSource = typeof(TicketsResources), + }; ticket.UpdateDetails(command.Title, command.Description, command.Priority); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs new file mode 100644 index 0000000000..472be0e99e --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Tickets.Localization; + +/// Marker type binding IStringLocalizer<TicketsResources> to the Tickets resx catalog. +public sealed class TicketsResources; diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx new file mode 100644 index 0000000000..aecc4dcec4 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Chamado {0} não encontrado. + + + Um chamado fechado não pode ser resolvido. Reabra-o primeiro. + + + Somente um chamado resolvido pode ser fechado. O status atual é {0}. Resolva-o primeiro. + + + Um chamado fechado não pode ser editado. Reabra-o primeiro. + + + Um chamado fechado não aceita novos comentários. Reabra-o primeiro. + + + Não é possível {0} um chamado no status {1}. Reabra-o primeiro. + + + Não é possível publicar um comentário sem um autor autenticado. + + + Não é possível criar um chamado sem um solicitante autenticado. + + diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx new file mode 100644 index 0000000000..6c74050874 --- /dev/null +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ticket {0} not found. + + + A closed ticket cannot be resolved — reopen it first. + + + Only a resolved ticket can be closed — current status is {0}. Resolve it first. + + + A closed ticket cannot be edited — reopen it first. + + + A closed ticket cannot accept new comments — reopen it first. + + + Cannot {0} a ticket in status {1} — reopen it first. + + + Cannot post a comment without an authenticated author. + + + Cannot create a ticket without an authenticated reporter. + + diff --git a/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj b/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj index f0058bb022..5324c71542 100644 --- a/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj +++ b/src/Modules/Tickets/Modules.Tickets/Modules.Tickets.csproj @@ -3,7 +3,8 @@ FSH.Modules.Tickets FSH.Modules.Tickets - $(NoWarn);CA1031;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1812;CA1859;S3267;S2094 diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs index 651f842910..638049555a 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/CreateWebhookSubscription/CreateWebhookSubscriptionCommandValidator.cs @@ -1,19 +1,21 @@ using FluentValidation; using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; +using FSH.Modules.Webhooks.Localization; using FSH.Modules.Webhooks.Services; +using Microsoft.Extensions.Localization; namespace FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; public sealed class CreateWebhookSubscriptionCommandValidator : AbstractValidator { - public CreateWebhookSubscriptionCommandValidator() + public CreateWebhookSubscriptionCommandValidator(IStringLocalizer localizer) { RuleFor(x => x.Url).NotEmpty() .Must(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - .WithMessage("A valid absolute URL is required.") + .WithMessage(_ => localizer["Validation.WebhookUrlInvalid"]) .Must(url => !Uri.TryCreate(url, UriKind.Absolute, out var uri) || !WebhookUrlGuard.IsBlockedHost(uri.Host)) - .WithMessage("The URL must not target a private, loopback, link-local, or metadata address."); - RuleFor(x => x.Events).NotEmpty().WithMessage("At least one event type is required."); + .WithMessage(_ => localizer["Validation.WebhookUrlBlockedTarget"]); + RuleFor(x => x.Events).NotEmpty().WithMessage(_ => localizer["Validation.WebhookEventsRequired"]); } } diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs index 2b550396df..d879925855 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/DeleteWebhookSubscription/DeleteWebhookSubscriptionCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Webhooks.Contracts.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Data; +using FSH.Modules.Webhooks.Localization; using Mediator; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public async ValueTask Handle(DeleteWebhookSubscriptionCommand command, Ca var subscription = await dbContext.Subscriptions .FirstOrDefaultAsync(s => s.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Webhook subscription {command.Id} not found."); + ?? throw new NotFoundException($"Webhook subscription {command.Id} not found.") + { + MessageKey = "Webhooks.SubscriptionNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(WebhooksResources), + }; dbContext.Subscriptions.Remove(subscription); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs index 92b97bc227..69918661b4 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Features/v1/TestWebhookSubscription/TestWebhookSubscriptionCommandHandler.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Modules.Webhooks.Contracts.v1.TestWebhookSubscription; using FSH.Modules.Webhooks.Data; +using FSH.Modules.Webhooks.Localization; using FSH.Modules.Webhooks.Services; using Mediator; using Microsoft.EntityFrameworkCore; @@ -21,7 +22,12 @@ public async ValueTask Handle(TestWebhookSubscriptionCommand command, Canc .AsNoTracking() .FirstOrDefaultAsync(s => s.Id == command.Id, cancellationToken) .ConfigureAwait(false) - ?? throw new NotFoundException($"Webhook subscription {command.Id} not found."); + ?? throw new NotFoundException($"Webhook subscription {command.Id} not found.") + { + MessageKey = "Webhooks.SubscriptionNotFound", + MessageArgs = [command.Id], + ResourceSource = typeof(WebhooksResources), + }; var testPayload = JsonSerializer.Serialize(new { diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs new file mode 100644 index 0000000000..dbcd1cec87 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.cs @@ -0,0 +1,4 @@ +namespace FSH.Modules.Webhooks.Localization; + +/// Marker type binding IStringLocalizer<WebhooksResources> to the Webhooks resx catalog. +public sealed class WebhooksResources; diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx new file mode 100644 index 0000000000..a22a34ec78 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.pt-BR.resx @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Inscrição de webhook {0} não encontrada. + + + Uma URL absoluta válida é obrigatória. + + + A URL não pode apontar para um endereço privado, de loopback, link-local ou de metadados. + + + É obrigatório pelo menos um tipo de evento. + + diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx new file mode 100644 index 0000000000..c86f71f6fa --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhooksResources.resx @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Webhook subscription {0} not found. + + + A valid absolute URL is required. + + + The URL must not target a private, loopback, link-local, or metadata address. + + + At least one event type is required. + + diff --git a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj index facaf13e82..6402b01143 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj +++ b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj @@ -2,7 +2,8 @@ FSH.Modules.Webhooks FSH.Modules.Webhooks - $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267 + + $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267;S2094 diff --git a/src/Tests/Architecture.Tests/CatalogParityTests.cs b/src/Tests/Architecture.Tests/CatalogParityTests.cs new file mode 100644 index 0000000000..a8a37b5d2e --- /dev/null +++ b/src/Tests/Architecture.Tests/CatalogParityTests.cs @@ -0,0 +1,183 @@ +using FSH.Framework.Core.Localization; +using Shouldly; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Resources; +using System.Text.RegularExpressions; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Generic key-parity guard across EVERY resx catalog, discovered by reflection. +/// +/// Each module already has a hand-written parity test, but those only cover the modules +/// someone remembered to write one for — Notifications shipped a catalog with no parity +/// test at all, and has no test project to put one in. This closes that class of gap: a +/// new module catalog is covered the moment its assembly lands in the output, with no new +/// test and no new test project. +/// +/// Parity matters because a key missing from a translated catalog does not fail — resource +/// fallback quietly serves the neutral (English) string, so an untranslated message ships +/// looking translated. +/// +public sealed class CatalogParityTests +{ + /// + /// A catalog marker is a type with an embedded `.resources` manifest matching its own + /// full name — which is exactly the co-located `ResourcesPath = ""` convention the + /// framework relies on. Anything else named `*Resources` is skipped. + /// + private static List DiscoverCatalogMarkers() + { + var assemblies = ModuleAssemblyDiscovery.GetModuleAssemblies() + .Append(typeof(SharedResources).Assembly) + .Distinct() + .ToList(); + + var markers = new List(); + foreach (var assembly in assemblies) + { + var manifests = assembly.GetManifestResourceNames(); + foreach (var type in SafeGetTypes(assembly)) + { + if (!type.IsClass || type.FullName is null) continue; + if (!type.Name.EndsWith("Resources", StringComparison.Ordinal)) continue; + if (manifests.Contains($"{type.FullName}.resources", StringComparer.Ordinal)) + { + markers.Add(type); + } + } + } + + return markers.OrderBy(t => t.FullName, StringComparer.Ordinal).ToList(); + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) { return ex.Types.Where(t => t is not null)!; } + } + + /// + /// Keys declared by this culture's OWN catalog. `tryParents: false` is the whole point: + /// with parent fallback on, a missing pt-BR key would be answered by the neutral catalog + /// and parity would look perfect while half the strings were English. + /// + private static Dictionary? OwnEntries(ResourceManager manager, CultureInfo culture) + { + var set = manager.GetResourceSet(culture, createIfNotExists: true, tryParents: false); + if (set is null) return null; + + var entries = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry entry in set) + { + if (entry.Key is string key) + { + entries[key] = entry.Value as string ?? string.Empty; + } + } + + return entries; + } + + /// + /// The `{0}`-style argument indexes a message consumes. Escaped braces (`{{`, `}}`) are + /// stripped first so a literal brace is not mistaken for a placeholder. + /// + private static SortedSet PlaceholderIndexes(string value) + { + var unescaped = value.Replace("{{", string.Empty, StringComparison.Ordinal) + .Replace("}}", string.Empty, StringComparison.Ordinal); + + var indexes = new SortedSet(); + foreach (var match in PlaceholderPattern.Matches(unescaped).Cast()) + { + indexes.Add(int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)); + } + + return indexes; + } + + private static readonly Regex PlaceholderPattern = + new(@"\{(\d+)(?::[^}]*)?\}", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + [Fact] + public void Every_Catalog_Has_Matching_Keys_In_Every_Supported_Culture() + { + var markers = DiscoverCatalogMarkers(); + + // Never let a discovery regression read as a pass: if the reflection stops finding + // catalogs, this test would otherwise assert nothing and go green. + markers.Count.ShouldBeGreaterThanOrEqualTo( + 11, + "expected the Core catalog plus one per module; discovery found fewer, so this " + + "test would silently stop guarding the ones it lost"); + + var violations = new List(); + + foreach (var marker in markers) + { + var manager = new ResourceManager(marker); + + var neutral = OwnEntries(manager, CultureInfo.InvariantCulture); + if (neutral is null || neutral.Count == 0) + { + violations.Add($"{marker.FullName}: neutral catalog is missing or empty"); + continue; + } + + foreach (var tag in SupportedCultures.Tags) + { + // The neutral catalog IS the default culture's catalog; there is no + // `*.en-US.resx` and there should not be one. + if (tag == SupportedCultures.Default) continue; + + var translated = OwnEntries(manager, new CultureInfo(tag)); + if (translated is null) + { + violations.Add($"{marker.FullName}: no `.{tag}.resx` catalog at all"); + continue; + } + + var missing = neutral.Keys.Except(translated.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var extra = translated.Keys.Except(neutral.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToList(); + + if (missing.Count > 0) + { + violations.Add($"{marker.FullName} [{tag}]: missing {missing.Count} key(s) — {string.Join(", ", missing)}"); + } + + if (extra.Count > 0) + { + violations.Add($"{marker.FullName} [{tag}]: {extra.Count} key(s) not in the neutral catalog — {string.Join(", ", extra)}"); + } + + // Matching keys are not enough. The caller passes ONE argument list for every + // culture, so a translation consuming a different set of `{n}` placeholders than + // the neutral string either drops data silently or throws FormatException at + // render time — in the translated culture only, i.e. never on the reviewer's + // machine. `{1}` present in Portuguese but not English is the dangerous + // direction: string.Format throws when the index is out of range. + foreach (var key in neutral.Keys.Intersect(translated.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) + { + var neutralArgs = PlaceholderIndexes(neutral[key]); + var translatedArgs = PlaceholderIndexes(translated[key]); + + if (!neutralArgs.SetEquals(translatedArgs)) + { + violations.Add( + $"{marker.FullName} [{tag}] key '{key}': placeholder mismatch — neutral uses " + + $"{{{string.Join(",", neutralArgs)}}} but {tag} uses {{{string.Join(",", translatedArgs)}}}"); + } + } + } + } + + violations.ShouldBeEmpty( + "Every resx catalog must declare the same keys in every supported culture. A key " + + "present only in the neutral catalog falls back to English and ships as if it were " + + "translated. Violations:\n " + string.Join("\n ", violations)); + } +} diff --git a/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs new file mode 100644 index 0000000000..3ee4bfbd3b --- /dev/null +++ b/src/Tests/Auditing.Tests/Localization/AuditingResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Auditing.Tests.Localization; + +// Proves the AuditingResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class AuditingResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(AuditingResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // AuditingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // AuditingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Validation.DateRangeOrder"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Validation.DateRangeOrder' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("FromUtc must be less than or equal to ToUtc."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Validation.DateRangeOrder"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Validation.DateRangeOrder' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("FromUtc deve ser menor ou igual a ToUtc."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs new file mode 100644 index 0000000000..6387ecfd6c --- /dev/null +++ b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Billing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Billing.Tests.Localization; + +// Proves the BillingResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class BillingResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(BillingResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // BillingResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // BillingResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Billing.OnlyRootOperatorMayGenerateInvoices"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Billing.OnlyRootOperatorMayGenerateInvoices' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Only the root operator may generate invoices across tenants."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Billing.OnlyRootOperatorMayGenerateInvoices"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Billing.OnlyRootOperatorMayGenerateInvoices' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Apenas o operador raiz pode gerar faturas entre tenants."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs new file mode 100644 index 0000000000..c77c031bf9 --- /dev/null +++ b/src/Tests/Catalog.Tests/Localization/CatalogResourcesTests.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Catalog.Tests.Localization; + +// Proves the CatalogResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class CatalogResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(CatalogResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // CatalogResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // CatalogResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Catalog.CategoryCannotBeOwnParent"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Catalog.CategoryCannotBeOwnParent' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("A category cannot be its own parent."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Catalog.CategoryCannotBeOwnParent"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Catalog.CategoryCannotBeOwnParent' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Uma categoria não pode ser pai de si mesma."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // The AdjustStock domain overflow message is localized with two positional args ({0}=delta, {1}=current stock). + [Fact] + public void StockAdjustmentNegative_formats_args_in_both_cultures() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Catalog.StockAdjustmentNegative", -4, 3]; + en.ResourceNotFound.ShouldBeFalse(); + en.Value.ShouldBe("Stock adjustment of -4 would result in negative stock (current: 3)."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Catalog.StockAdjustmentNegative", -4, 3]; + pt.ResourceNotFound.ShouldBeFalse(); + pt.Value.ShouldBe("O ajuste de estoque de -4 resultaria em estoque negativo (atual: 3)."); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs b/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..cc6beedd0a --- /dev/null +++ b/src/Tests/Catalog.Tests/Support/CatalogResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Catalog.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Catalog.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class CatalogResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs b/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs new file mode 100644 index 0000000000..b86c1ec57a --- /dev/null +++ b/src/Tests/Catalog.Tests/Validators/AdjustProductStockCommandValidatorTests.cs @@ -0,0 +1,35 @@ +using Catalog.Tests.Support; +using FSH.Modules.Catalog.Contracts.v1.Products; +using FSH.Modules.Catalog.Features.v1.Products.AdjustProductStock; + +namespace Catalog.Tests.Validators; + +// Exercises the validator with a REAL IStringLocalizer so the localized +// message key ("Validation.DeltaNonZero") is proven to resolve against the embedded catalog. +public sealed class AdjustProductStockCommandValidatorTests +{ + private readonly AdjustProductStockCommandValidator _sut = new(CatalogResourcesLocalizerFactory.Create()); + + [Fact] + public void Delta_Should_Pass_When_NonZero() + { + var command = new AdjustProductStockCommand(Guid.NewGuid(), 5); + + var result = _sut.Validate(command); + + result.Errors.ShouldNotContain(e => e.PropertyName == nameof(AdjustProductStockCommand.Delta)); + } + + [Fact] + public void Delta_Should_Fail_With_LocalizedMessage_When_Zero() + { + var command = new AdjustProductStockCommand(Guid.NewGuid(), 0); + + var result = _sut.Validate(command); + + result.IsValid.ShouldBeFalse(); + result.Errors + .Where(e => e.PropertyName == nameof(AdjustProductStockCommand.Delta)) + .ShouldContain(e => e.ErrorMessage == "Delta must be non-zero."); + } +} diff --git a/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs b/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs new file mode 100644 index 0000000000..943d0f9c75 --- /dev/null +++ b/src/Tests/Chat.Tests/Localization/ChatResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Chat.Tests.Localization; + +// Proves the ChatResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class ChatResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(ChatResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // ChatResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // ChatResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Chat.ChannelNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Chat.ChannelNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Channel not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Chat.ChannelNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Chat.ChannelNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Canal não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs b/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..5f02505aa0 --- /dev/null +++ b/src/Tests/Chat.Tests/Support/ChatResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Chat.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Chat.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class ChatResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs new file mode 100644 index 0000000000..f5dfbde6e1 --- /dev/null +++ b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Files.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Files.Tests.Localization; + +// Proves the FilesResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class FilesResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(FilesResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // FilesResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // FilesResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Files.FileNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Files.FileNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("File not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Files.FileNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Files.FileNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Arquivo não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs b/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..43d0e9fd0f --- /dev/null +++ b/src/Tests/Generic.Tests/Support/AuditingResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Auditing.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Generic.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so the Auditing validators that require the module +// localizer can be instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class AuditingResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs b/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..2580fb2deb --- /dev/null +++ b/src/Tests/Generic.Tests/Support/SharedResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Framework.Core.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Generic.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class SharedResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs index 2fd0253ac6..52215fb250 100644 --- a/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs +++ b/src/Tests/Generic.Tests/Validators/DateRangeValidatorTests.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAudits; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByCorrelation; using FSH.Modules.Auditing.Contracts.v1.GetAuditsByTrace; @@ -10,6 +11,9 @@ using FSH.Modules.Auditing.Features.v1.GetAuditSummary; using FSH.Modules.Auditing.Features.v1.GetExceptionAudits; using FSH.Modules.Auditing.Features.v1.GetSecurityAudits; +using FSH.Modules.Auditing.Localization; +using Generic.Tests.Support; +using Microsoft.Extensions.Localization; namespace Generic.Tests.Validators; @@ -20,12 +24,14 @@ namespace Generic.Tests.Validators; public sealed class DateRangeValidatorTests { private static readonly DateTime BaseDate = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); + private static readonly IStringLocalizer Localizer = SharedResourcesLocalizerFactory.Create(); + private static readonly IStringLocalizer AuditingLocalizer = AuditingResourcesLocalizerFactory.Create(); [Fact] public void DateRange_Should_Pass_When_BothNull_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = null, ToUtc = null }; // Act @@ -39,7 +45,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAudits() public void DateRange_Should_Pass_When_BothNull_GetAuditsByCorrelation() { // Arrange - var validator = new GetAuditsByCorrelationQueryValidator(); + var validator = new GetAuditsByCorrelationQueryValidator(AuditingLocalizer); var query = new GetAuditsByCorrelationQuery { CorrelationId = "test-id", FromUtc = null, ToUtc = null }; // Act @@ -53,7 +59,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditsByCorrelation() public void DateRange_Should_Pass_When_BothNull_GetAuditsByTrace() { // Arrange - var validator = new GetAuditsByTraceQueryValidator(); + var validator = new GetAuditsByTraceQueryValidator(AuditingLocalizer); var query = new GetAuditsByTraceQuery { TraceId = "test-trace", FromUtc = null, ToUtc = null }; // Act @@ -67,7 +73,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditsByTrace() public void DateRange_Should_Pass_When_BothNull_GetAuditSummary() { // Arrange - var validator = new GetAuditSummaryQueryValidator(); + var validator = new GetAuditSummaryQueryValidator(AuditingLocalizer); var query = new GetAuditSummaryQuery { FromUtc = null, ToUtc = null }; // Act @@ -81,7 +87,7 @@ public void DateRange_Should_Pass_When_BothNull_GetAuditSummary() public void DateRange_Should_Pass_When_OnlyFromUtcSet_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = null }; // Act @@ -95,7 +101,7 @@ public void DateRange_Should_Pass_When_OnlyFromUtcSet_GetAudits() public void DateRange_Should_Pass_When_OnlyToUtcSet_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = null, ToUtc = BaseDate }; // Act @@ -109,7 +115,7 @@ public void DateRange_Should_Pass_When_OnlyToUtcSet_GetAudits() public void DateRange_Should_Pass_When_FromUtcEqualsToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, ToUtc = BaseDate }; // Act @@ -123,7 +129,7 @@ public void DateRange_Should_Pass_When_FromUtcEqualsToUtc_GetAudits() public void DateRange_Should_Pass_When_FromUtcBeforeToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, @@ -141,7 +147,7 @@ public void DateRange_Should_Pass_When_FromUtcBeforeToUtc_GetAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAudits() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -160,7 +166,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByCorrelation() { // Arrange - var validator = new GetAuditsByCorrelationQueryValidator(); + var validator = new GetAuditsByCorrelationQueryValidator(AuditingLocalizer); var query = new GetAuditsByCorrelationQuery { CorrelationId = "test-id", @@ -180,7 +186,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByCorrelation( public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByTrace() { // Arrange - var validator = new GetAuditsByTraceQueryValidator(); + var validator = new GetAuditsByTraceQueryValidator(AuditingLocalizer); var query = new GetAuditsByTraceQuery { TraceId = "test-trace", @@ -200,7 +206,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditsByTrace() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditSummary() { // Arrange - var validator = new GetAuditSummaryQueryValidator(); + var validator = new GetAuditSummaryQueryValidator(AuditingLocalizer); var query = new GetAuditSummaryQuery { FromUtc = BaseDate.AddDays(7), @@ -219,7 +225,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetAuditSummary() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetExceptionAudits() { // Arrange - var validator = new GetExceptionAuditsQueryValidator(); + var validator = new GetExceptionAuditsQueryValidator(AuditingLocalizer); var query = new GetExceptionAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -238,7 +244,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetExceptionAudits() public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetSecurityAudits() { // Arrange - var validator = new GetSecurityAuditsQueryValidator(); + var validator = new GetSecurityAuditsQueryValidator(AuditingLocalizer); var query = new GetSecurityAuditsQuery { FromUtc = BaseDate.AddDays(7), @@ -260,7 +266,7 @@ public void DateRange_Should_Fail_When_FromUtcAfterToUtc_GetSecurityAudits() public void DateRange_Should_Pass_When_FromUtcSlightlyBeforeToUtc(int secondsDiff) { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { FromUtc = BaseDate, diff --git a/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs index 48ed96d00b..843ee8d841 100644 --- a/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs +++ b/src/Tests/Generic.Tests/Validators/PagedQueryValidatorTests.cs @@ -1,7 +1,11 @@ +using FSH.Framework.Core.Localization; using FSH.Modules.Auditing.Contracts.v1.GetAudits; using FSH.Modules.Auditing.Features.v1.GetAudits; +using FSH.Modules.Auditing.Localization; using FSH.Modules.Identity.Contracts.v1.Users.SearchUsers; using FSH.Modules.Identity.Features.v1.Users.SearchUsers; +using Generic.Tests.Support; +using Microsoft.Extensions.Localization; namespace Generic.Tests.Validators; @@ -11,10 +15,13 @@ namespace Generic.Tests.Validators; /// public sealed class PagedQueryValidatorTests { + private static readonly IStringLocalizer Localizer = SharedResourcesLocalizerFactory.Create(); + private static readonly IStringLocalizer AuditingLocalizer = AuditingResourcesLocalizerFactory.Create(); + public static TheoryData PagedQueryValidators => new() { - { new GetAuditsQueryValidator(), new GetAuditsQuery() }, - { new SearchUsersQueryValidator(), new SearchUsersQuery() } + { new GetAuditsQueryValidator(Localizer, AuditingLocalizer), new GetAuditsQuery() }, + { new SearchUsersQueryValidator(Localizer), new SearchUsersQuery() } }; [Theory] @@ -35,7 +42,7 @@ public void PageNumber_Should_Pass_When_Null(IValidator validator, object query) public void PageNumber_Should_Pass_When_GreaterThanZero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = 1 }; // Act @@ -49,7 +56,7 @@ public void PageNumber_Should_Pass_When_GreaterThanZero_Auditing() public void PageNumber_Should_Pass_When_GreaterThanZero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = 5 }; // Act @@ -63,7 +70,7 @@ public void PageNumber_Should_Pass_When_GreaterThanZero_Identity() public void PageNumber_Should_Fail_When_Zero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = 0 }; // Act @@ -77,7 +84,7 @@ public void PageNumber_Should_Fail_When_Zero_Auditing() public void PageNumber_Should_Fail_When_Zero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = 0 }; // Act @@ -91,7 +98,7 @@ public void PageNumber_Should_Fail_When_Zero_Identity() public void PageNumber_Should_Fail_When_Negative_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageNumber = -1 }; // Act @@ -105,7 +112,7 @@ public void PageNumber_Should_Fail_When_Negative_Auditing() public void PageNumber_Should_Fail_When_Negative_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageNumber = -5 }; // Act @@ -119,7 +126,7 @@ public void PageNumber_Should_Fail_When_Negative_Identity() public void PageSize_Should_Pass_When_Null_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = null }; // Act @@ -133,7 +140,7 @@ public void PageSize_Should_Pass_When_Null_Auditing() public void PageSize_Should_Pass_When_Null_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = null }; // Act @@ -150,7 +157,7 @@ public void PageSize_Should_Pass_When_Null_Identity() public void PageSize_Should_Pass_When_Between1And100_Auditing(int pageSize) { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = pageSize }; // Act @@ -167,7 +174,7 @@ public void PageSize_Should_Pass_When_Between1And100_Auditing(int pageSize) public void PageSize_Should_Pass_When_Between1And100_Identity(int pageSize) { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = pageSize }; // Act @@ -181,7 +188,7 @@ public void PageSize_Should_Pass_When_Between1And100_Identity(int pageSize) public void PageSize_Should_Fail_When_Zero_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = 0 }; // Act @@ -195,7 +202,7 @@ public void PageSize_Should_Fail_When_Zero_Auditing() public void PageSize_Should_Fail_When_Zero_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = 0 }; // Act @@ -209,7 +216,7 @@ public void PageSize_Should_Fail_When_Zero_Identity() public void PageSize_Should_Fail_When_GreaterThan100_Auditing() { // Arrange - var validator = new GetAuditsQueryValidator(); + var validator = new GetAuditsQueryValidator(Localizer, AuditingLocalizer); var query = new GetAuditsQuery { PageSize = 101 }; // Act @@ -223,7 +230,7 @@ public void PageSize_Should_Fail_When_GreaterThan100_Auditing() public void PageSize_Should_Fail_When_GreaterThan100_Identity() { // Arrange - var validator = new SearchUsersQueryValidator(); + var validator = new SearchUsersQueryValidator(Localizer); var query = new SearchUsersQuery { PageSize = 150 }; // Act diff --git a/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs b/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs new file mode 100644 index 0000000000..283ab6886b --- /dev/null +++ b/src/Tests/Identity.Tests/Localization/IdentityResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Identity.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Identity.Tests.Localization; + +// Proves the IdentityResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class IdentityResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(IdentityResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // IdentityResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // IdentityResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Identity.UserNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Identity.UserNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("User not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Identity.UserNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Identity.UserNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Usuário não encontrado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs index 72e8a0e36f..de8a53d385 100644 --- a/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/CreateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; using FSH.Modules.Identity.Features.v1.Groups.CreateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class CreateGroupCommandValidatorTests { - private readonly CreateGroupCommandValidator _sut = new(); + private readonly CreateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation diff --git a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs index 399d4de894..d85b145aa6 100644 --- a/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/DeleteUserCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; using FSH.Modules.Identity.Features.v1.Users.DeleteUser; +using Identity.Tests.Support; using Shouldly; using Xunit; @@ -7,7 +8,7 @@ namespace Identity.Tests.Validators; public sealed class DeleteUserCommandValidatorTests { - private readonly DeleteUserCommandValidator _sut = new(); + private readonly DeleteUserCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); [Fact] public void Validate_Should_Pass_When_IdIsProvided() diff --git a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs index c0c1cdb2b9..305a1c76d7 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; using FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpdateGroupCommandValidatorTests { - private readonly UpdateGroupCommandValidator _sut = new(); + private readonly UpdateGroupCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Id Validation diff --git a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs index 3fe0559c2c..500b8f7a54 100644 --- a/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpsertRoleCommandValidatorTests.cs @@ -1,5 +1,6 @@ using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; using FSH.Modules.Identity.Features.v1.Roles.UpsertRole; +using Identity.Tests.Support; namespace Identity.Tests.Validators; @@ -8,7 +9,7 @@ namespace Identity.Tests.Validators; /// public sealed class UpsertRoleCommandValidatorTests { - private readonly UpsertRoleCommandValidator _sut = new(); + private readonly UpsertRoleCommandValidator _sut = new(SharedResourcesLocalizerFactory.Create()); #region Name Validation diff --git a/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs new file mode 100644 index 0000000000..1f360adce3 --- /dev/null +++ b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Multitenancy.Tests.Localization; + +// Proves the MultitenancyResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class MultitenancyResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(MultitenancyResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // MultitenancyResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // MultitenancyResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Multitenancy.RootTenantCannotBeDeactivated"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Multitenancy.RootTenantCannotBeDeactivated' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("The root tenant cannot be deactivated."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Multitenancy.RootTenantCannotBeDeactivated"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Multitenancy.RootTenantCannotBeDeactivated' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("O tenant raiz não pode ser desativado."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs b/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..5d8c6a6236 --- /dev/null +++ b/src/Tests/Multitenancy.Tests/Support/MultitenancyResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Multitenancy.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Multitenancy.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class MultitenancyResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Tickets.Tests/GlobalUsings.cs b/src/Tests/Tickets.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..3a6ad15e89 --- /dev/null +++ b/src/Tests/Tickets.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Shouldly; +global using Xunit; diff --git a/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs new file mode 100644 index 0000000000..193ab6f73a --- /dev/null +++ b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Tickets.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Tickets.Tests.Localization; + +// Proves the TicketsResources catalog is embedded under the correct manifest name so the module +// resx resolves at runtime. A wrong manifest name would flip ResourceNotFound and leak raw keys or +// English text, and a missing pt-BR entry would ship English as if it were translated. Both are caught +// here. This is the module's only unit test project, added when Tickets exception bodies were localized. +public sealed class TicketsResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(TicketsResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // TicketsResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // TicketsResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Tickets.ClosedCannotResolve"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Tickets.ClosedCannotResolve' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("A closed ticket cannot be resolved — reopen it first."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Tickets.ClosedCannotResolve"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Tickets.ClosedCannotResolve' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Um chamado fechado não pode ser resolvido. Reabra-o primeiro."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + // TicketNotFound carries the ticket id ({0}); OnlyResolvedCanClose carries the status ({0}). + [Fact] + public void Parameterized_keys_format_args_in_both_cultures() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + localizer["Tickets.TicketNotFound", "abc"].Value.ShouldBe("Ticket abc not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + localizer["Tickets.TicketNotFound", "abc"].Value.ShouldBe("Chamado abc não encontrado."); + localizer["Tickets.OnlyResolvedCanClose", "Open"].Value + .ShouldBe("Somente um chamado resolvido pode ser fechado. O status atual é Open. Resolva-o primeiro."); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Tickets.Tests/Tickets.Tests.csproj b/src/Tests/Tickets.Tests/Tickets.Tests.csproj new file mode 100644 index 0000000000..0cc930f5df --- /dev/null +++ b/src/Tests/Tickets.Tests/Tickets.Tests.csproj @@ -0,0 +1,25 @@ + + + + Tickets.Tests + Tickets.Tests + false + true + $(NoWarn);CA1515;CA1861;CA1707 + + + + + + + + + + + + + + + + + diff --git a/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs index e4bef7aa96..b43c0313fa 100644 --- a/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs +++ b/src/Tests/Webhooks.Tests/CreateWebhookSubscriptionSsrfValidatorTests.cs @@ -2,6 +2,7 @@ using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Services; +using Webhooks.Tests.Support; namespace Webhooks.Tests; @@ -13,7 +14,7 @@ namespace Webhooks.Tests; /// public sealed class CreateWebhookSubscriptionSsrfValidatorTests { - private readonly CreateWebhookSubscriptionCommandValidator _validator = new(); + private readonly CreateWebhookSubscriptionCommandValidator _validator = new(WebhooksResourcesLocalizerFactory.Create()); [Theory] [InlineData("http://169.254.169.254/latest/meta-data/")] // cloud instance metadata diff --git a/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs b/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs new file mode 100644 index 0000000000..b09e8ba919 --- /dev/null +++ b/src/Tests/Webhooks.Tests/Localization/WebhooksResourcesTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Linq; +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Webhooks.Tests.Localization; + +// Proves the WebhooksResources catalog is embedded under the correct manifest name (ResourcesPath="" => +// co-located marker + resx). A wrong manifest name flips ResourceNotFound and leaks raw keys; a +// missing pt-BR entry ships English as "translated". Both are caught here. +public sealed class WebhooksResourcesTests +{ + private static IStringLocalizer BuildLocalizer() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider() + .GetRequiredService() + .Create(typeof(WebhooksResources)); + } + + private static List KeysFor(string culture) + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = culture.Length == 0 + ? CultureInfo.InvariantCulture + : new CultureInfo(culture); + return localizer.GetAllStrings(includeParentCultures: false) + .Select(s => s.Name) + .ToList(); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + [Fact] + public void Neutral_and_ptBR_catalogs_have_matching_keys() + { + var neutral = KeysFor(string.Empty); // WebhooksResources.resx (English / fallback) + var pt = KeysFor("pt-BR"); // WebhooksResources.pt-BR.resx + + neutral.ShouldNotBeEmpty(); + pt.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); + } + + [Fact] + public void Known_key_resolves_and_differs_between_en_and_pt() + { + var localizer = BuildLocalizer(); + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var en = localizer["Webhooks.SubscriptionNotFound"]; + en.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Webhooks.SubscriptionNotFound' for en-US — check ResourcesPath/resx manifest name."); + en.Value.ShouldBe("Webhook subscription {0} not found."); + + CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); + var pt = localizer["Webhooks.SubscriptionNotFound"]; + pt.ResourceNotFound.ShouldBeFalse( + "resx did not resolve 'Webhooks.SubscriptionNotFound' for pt-BR — check the .pt-BR catalog manifest name."); + pt.Value.ShouldBe("Inscrição de webhook {0} não encontrada."); + + pt.Value.ShouldNotBe(en.Value); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } +} diff --git a/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs b/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs new file mode 100644 index 0000000000..d14626c129 --- /dev/null +++ b/src/Tests/Webhooks.Tests/Support/WebhooksResourcesLocalizerFactory.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace Webhooks.Tests.Support; + +// Builds a REAL IStringLocalizer bound to the embedded resx catalog +// (ResourcesPath="" — co-located marker + resx) so validators that require a localizer can be +// instantiated in unit tests exercising the actual catalog rather than a stub. +internal static class WebhooksResourcesLocalizerFactory +{ + public static IStringLocalizer Create() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddLocalization(o => o.ResourcesPath = ""); + return services.BuildServiceProvider().GetRequiredService>(); + } +} diff --git a/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs b/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs index c7794b29a4..54a8953374 100644 --- a/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs +++ b/src/Tests/Webhooks.Tests/Validators/WebhookValidatorTests.cs @@ -1,20 +1,25 @@ using FSH.Modules.Webhooks.Contracts.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Contracts.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Contracts.v1.TestWebhookSubscription; +using FSH.Modules.Webhooks.Localization; +using Microsoft.Extensions.Localization; using FSH.Modules.Webhooks.Features.v1.CreateWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.DeleteWebhookSubscription; using FSH.Modules.Webhooks.Features.v1.TestWebhookSubscription; +using Webhooks.Tests.Support; namespace Webhooks.Tests.Validators; public sealed class WebhookValidatorTests { + private static readonly IStringLocalizer Localizer = WebhooksResourcesLocalizerFactory.Create(); + #region CreateWebhookSubscription [Fact] public void Create_Should_Pass_When_Url_Absolute_And_Events_Present() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("https://example.com/hook", ["user.created"], "secret"); var result = validator.Validate(command); @@ -25,7 +30,7 @@ public void Create_Should_Pass_When_Url_Absolute_And_Events_Present() [Fact] public void Create_Should_Fail_When_Url_Empty() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand(string.Empty, ["user.created"], null); var result = validator.Validate(command); @@ -37,7 +42,7 @@ public void Create_Should_Fail_When_Url_Empty() [Fact] public void Create_Should_Fail_When_Url_Not_Absolute() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("not-a-url", ["user.created"], null); var result = validator.Validate(command); @@ -49,7 +54,7 @@ public void Create_Should_Fail_When_Url_Not_Absolute() [Fact] public void Create_Should_Fail_When_Url_Relative() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("/relative/path", ["user.created"], null); var result = validator.Validate(command); @@ -60,7 +65,7 @@ public void Create_Should_Fail_When_Url_Relative() [Fact] public void Create_Should_Fail_When_Events_Empty() { - var validator = new CreateWebhookSubscriptionCommandValidator(); + var validator = new CreateWebhookSubscriptionCommandValidator(Localizer); var command = new CreateWebhookSubscriptionCommand("https://example.com", [], null); var result = validator.Validate(command); From 28c3aaa270c5b7b0741ae04e3803167d8cf43db5 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:59:22 -0300 Subject: [PATCH 03/19] fix(localization): resolve exception bodies under the negotiated request culture No exception message this PR localizes was actually translated at runtime. Every detail resolved from a MessageKey and every title mapped from a status code came back from the neutral resx, whatever the client asked for. UseExceptionHandler is registered above UseHeroLocalization, and RequestLocalizationMiddleware assigns CultureInfo.CurrentUICulture inside its own async frame. That assignment belongs to the ExecutionContext of that frame and is gone by the time an exception unwinds up to the handler, so every localizer there resolved under the culture of the host process -- the invariant one in a container with no LANG, hence the neutral resx. GlobalExceptionHandler now reads the culture from HttpContext.Features.Get(), which the middleware sets on the request itself and therefore survives the unwind. Reading the negotiated culture rather than re-reading Accept-Language keeps the whole provider chain, including the user locale claim. Only CurrentUICulture is touched: AddHeroLocalization pins CurrentCulture to invariant on purpose. The previous value is restored in a finally so no request culture leaks onto the thread. When no feature is present -- an exception escaping before localization runs -- the ambient culture stands and no Content-Language is claimed. Also restores Content-Language on the problem body. ExceptionHandlerMiddleware clears the response before re-executing, which drops the header the localization middleware had already written, leaving the culture of the prose undeclared. GlobalExceptionHandlerLocalizationTests could not catch this: they assign CurrentUICulture by hand and call the handler directly, never through a pipeline. ExceptionLocalizationPipelineTests build the real pipeline and pin the ambient culture to invariant, which is what a container gives the API -- without that pin a developer machine whose own culture is the tested one reports a false pass. Against the handler as it stood before this commit, five of those cases fail; the negotiation baseline and the no-localization case pass either way. --- .../Web/Exceptions/GlobalExceptionHandler.cs | 44 +++++ .../Web/ExceptionLocalizationPipelineTests.cs | 187 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index 903f6704ed..e585536714 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -1,10 +1,12 @@ using System.Diagnostics; using System; +using System.Globalization; using System.Net; using FSH.Framework.Core.Exceptions; using FSH.Framework.Core.Localization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; @@ -73,11 +75,44 @@ private void ApplyLocalizedDetail(ProblemDetails problemDetails, ILocalizableMes } } + // UseExceptionHandler sits ABOVE UseHeroLocalization in the pipeline, and + // RequestLocalizationMiddleware assigns CultureInfo.CurrentUICulture inside its own async frame — + // an assignment that belongs to that frame's ExecutionContext and is already gone by the time an + // exception unwinds up to this handler. Every localizer below would therefore resolve under the + // culture of the host process (the invariant one in a container with no LANG), and answer from the + // neutral resx no matter what the client asked for. The negotiated culture survives on the request + // itself, so take it from there and restore the ambient one afterwards. + // + // CurrentUICulture only: AddHeroLocalization pins CurrentCulture to invariant on purpose, so that + // no request can shift numeric or date formatting anywhere in the pipeline. public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(httpContext); ArgumentNullException.ThrowIfNull(exception); + var requestUiCulture = httpContext.Features.Get()?.RequestCulture.UICulture; + + // No feature means the exception escaped before localization ran (CORS, security headers, + // forwarded headers). Nothing was negotiated, so the ambient culture is all there is. + if (requestUiCulture is null) + { + return await WriteProblemDetailsAsync(httpContext, exception, requestUiCulture: null, cancellationToken).ConfigureAwait(false); + } + + var previousUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = requestUiCulture; + try + { + return await WriteProblemDetailsAsync(httpContext, exception, requestUiCulture, cancellationToken).ConfigureAwait(false); + } + finally + { + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + + private async ValueTask WriteProblemDetailsAsync(HttpContext httpContext, Exception exception, CultureInfo? requestUiCulture, CancellationToken cancellationToken) + { var problemDetails = new ProblemDetails { Instance = httpContext.Request.Path @@ -165,6 +200,15 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e httpContext.Response.StatusCode = statusCode; + // ExceptionHandlerMiddleware clears the response before re-executing, which drops the + // Content-Language RequestLocalizationMiddleware had already written. Put it back, so a client + // can tell which culture the prose in this body is in. The invariant culture has an empty name + // and is not a valid header value. + if (requestUiCulture is not null && requestUiCulture.Name.Length > 0) + { + httpContext.Response.Headers.ContentLanguage = requestUiCulture.Name; + } + // Surface trace and correlation IDs so clients/support can correlate errors to traces var traceId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier; problemDetails.Extensions["traceId"] = traceId; diff --git a/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs new file mode 100644 index 0000000000..6efe140041 --- /dev/null +++ b/src/Tests/Framework.Tests/Web/ExceptionLocalizationPipelineTests.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Web.Exceptions; +using FSH.Framework.Web.Localization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Framework.Tests.Web; + +// Pipeline-level counterpart to GlobalExceptionHandlerLocalizationTests. Those tests assign +// CultureInfo.CurrentUICulture by hand and call the handler directly, so they never exercise the one +// thing production depends on: that the culture RequestLocalizationMiddleware negotiates is still +// visible to the handler, which UseExceptionHandler runs from ABOVE that middleware. It is not — the +// assignment lives in the middleware own async frame — so the handler has to read the negotiated +// culture off the request instead. +// +// The ambient culture is pinned to invariant for the duration of each case, which is what a container +// with no LANG gives the API. Without the pin the developer machine culture decides the outcome and a +// pt-BR machine reports a false pass. +public sealed class ExceptionLocalizationPipelineTests +{ + private sealed record Outcome(string? Title, string? Detail, string? Code, string? NegotiatedUiCulture, string? ContentLanguage); + + // The production order: UseExceptionHandler at the top of the pipeline, localization further in. + private static Task InvokeAsync(string acceptLanguage, Exception exception) => + RunAsync(app => app.UseHeroLocalization(), acceptLanguage, exception); + + // A pipeline with no localization at all, standing in for the middleware registered between + // UseExceptionHandler and UseHeroLocalization: nothing negotiates a culture, so no feature exists. + private static Task InvokeWithoutLocalizationAsync(string acceptLanguage, Exception exception) => + RunAsync(_ => { }, acceptLanguage, exception); + + private static async Task RunAsync(Action configure, string acceptLanguage, Exception exception) + { + var previousCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + try + { + var configuration = new ConfigurationBuilder().Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMetrics(); + + // ExceptionHandlerMiddlewareImpl is activated from DI and takes a DiagnosticListener; + // the real host gets it from WebApplicationBuilder, so a bare ServiceCollection must supply it. + services.AddSingleton(_ => new DiagnosticListener("Microsoft.AspNetCore")); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddHeroLocalization(configuration); + services.AddExceptionHandler(); + services.AddProblemDetails(); + + await using var provider = services.BuildServiceProvider(); + + var app = new ApplicationBuilder(provider); + app.UseExceptionHandler(); + configure(app); + app.Run(_ => throw exception); + var pipeline = app.Build(); + + using var scope = provider.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Request.Path = "/api/v1/test"; + context.Request.Headers.AcceptLanguage = acceptLanguage; + using var body = new MemoryStream(); + context.Response.Body = body; + + await pipeline(context); + + using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(body.ToArray())); + var root = doc.RootElement; + var contentLanguage = context.Response.Headers.ContentLanguage.ToString(); + return new Outcome( + root.TryGetProperty("title", out var t) ? t.GetString() : null, + root.TryGetProperty("detail", out var d) ? d.GetString() : null, + root.TryGetProperty("code", out var c) ? c.GetString() : null, + context.Features.Get()?.RequestCulture.UICulture.Name, + string.IsNullOrEmpty(contentLanguage) ? null : contentLanguage); + } + finally + { + CultureInfo.CurrentUICulture = previousCulture; + } + } + + // Baseline: the middleware DOES negotiate the requested UI culture. If this fails, the gap is in + // negotiation and the assertions below say nothing about the handler. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task Request_culture_is_negotiated_from_the_accept_language_header(string acceptLanguage) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.NegotiatedUiCulture.ShouldBe(acceptLanguage); + } + + // The production symptom: a localized exception answered in English despite Accept-Language: pt-BR. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task Detail_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.Code.ShouldBe("Error.NotFound"); + outcome.Detail.ShouldBe(expected); + } + + // Title comes from the status-mapped catalog key through the injected IStringLocalizer, a separate + // resolution path from Detail IStringLocalizerFactory. Both read the ambient culture, so both broke. + [Theory] + [InlineData("pt-BR", "Não encontrado")] + [InlineData("en-US", "Not Found")] + public async Task Title_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var outcome = await InvokeAsync(acceptLanguage, new NotFoundException("English fallback")); + + outcome.Title.ShouldBe(expected); + } + + // A raw exception takes the 500 branch, whose Title and Detail come straight off the injected + // localizer with no MessageKey involved. + [Theory] + [InlineData("pt-BR", "Ocorreu um erro inesperado")] + [InlineData("en-US", "An unexpected error occurred")] + public async Task Unexpected_error_is_localized_from_the_negotiated_request_culture(string acceptLanguage, string expected) + { + var outcome = await InvokeAsync(acceptLanguage, new InvalidOperationException("boom")); + + outcome.Title.ShouldBe(expected); + } + + // ExceptionHandlerMiddleware clears the response before re-executing, dropping the Content-Language + // the localization middleware had written. The handler restores it so the body culture is declared. + [Theory] + [InlineData("pt-BR")] + [InlineData("en-US")] + public async Task Content_language_declares_the_culture_the_body_was_written_in(string acceptLanguage) + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeAsync(acceptLanguage, exception); + + outcome.ContentLanguage.ShouldBe(acceptLanguage); + } + + // No localization in the pipeline: the handler must still answer, from the ambient culture, and + // must not claim a Content-Language it never negotiated. + [Fact] + public async Task Handler_still_answers_when_no_culture_was_negotiated() + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + + var outcome = await InvokeWithoutLocalizationAsync("pt-BR", exception); + + outcome.NegotiatedUiCulture.ShouldBeNull(); + outcome.ContentLanguage.ShouldBeNull(); + outcome.Code.ShouldBe("Error.NotFound"); + outcome.Detail.ShouldBe("Not Found"); + outcome.Title.ShouldBe("Not Found"); + } + + // The ambient culture is restored on the way out, so the handler cannot leak the request culture + // onto whatever else runs on this thread afterwards. + [Fact] + public async Task Ambient_culture_is_restored_after_handling() + { + var exception = new NotFoundException("English fallback") { MessageKey = "Error.NotFound" }; + var before = CultureInfo.CurrentUICulture; + + await InvokeAsync("pt-BR", exception); + + CultureInfo.CurrentUICulture.ShouldBe(before); + } +} From 92c62f7eb0f44615d6cbfa9f7244a6001a05ecb5 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:44:42 -0300 Subject: [PATCH 04/19] build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. --- src/Directory.Packages.props | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7674befa8f..89162470ad 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -9,7 +9,8 @@ - + + @@ -122,9 +123,10 @@ - - - + + + + From 176233343fd34e1d66d512eaee1a9926dafe2524 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:44:42 -0300 Subject: [PATCH 05/19] build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. --- src/Directory.Packages.props | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7674befa8f..89162470ad 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -9,7 +9,8 @@ - + + @@ -122,9 +123,10 @@ - - - + + + + From faf81655c12f549447ae55d6b7911cd62875f645 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:45:29 -0300 Subject: [PATCH 06/19] fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. --- deploy/docker/README.md | 6 +++--- deploy/docker/docker-compose.yml | 3 ++- src/Host/FSH.Starter.AppHost/AppHost.cs | 3 +++ .../Infrastructure/MiddlewareWebApplicationFactory.cs | 3 ++- .../Infrastructure/FshWebApplicationFactory.cs | 3 ++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/deploy/docker/README.md b/deploy/docker/README.md index bcb1304593..0219164b7c 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -8,9 +8,9 @@ This brings up the full stack on a single host: | `admin` | `fsh/admin:local` | `FSH_ADMIN_PORT` (default 8081) | Operator console (nginx + React) | | `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) | | `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user | -| `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas | -| `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | -| `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module | +| `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas | +| `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | +| `minio` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` | (internal) | S3-compatible blob store for the Files module | The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine. diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index d43c744f5b..9457a61232 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -54,7 +54,8 @@ services: # - "6379:6379" minio: - image: minio/minio:latest + # quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. + image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z container_name: fsh-minio restart: unless-stopped command: ["server", "/data", "--console-address", ":9001"] diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs index e7a70abd05..fb3506ab42 100644 --- a/src/Host/FSH.Starter.AppHost/AppHost.cs +++ b/src/Host/FSH.Starter.AppHost/AppHost.cs @@ -52,7 +52,10 @@ var minioUser = builder.AddParameter("minio-user", "minioadmin"); var minioPassword = builder.AddParameter("minio-password", "minioadmin", secret: true); +// quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. var minio = builder.AddContainer("minio", "minio/minio") + .WithImageRegistry("quay.io") + .WithImageTag("RELEASE.2025-09-07T16-13-09Z") .WithArgs("server", "/data", "--console-address", ":9001") .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console") diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs index 4c2939c454..e8b7898023 100644 --- a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs +++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs @@ -55,7 +55,8 @@ public sealed class MiddlewareWebApplicationFactory : WebApplicationFactory, I .WithCleanUp(true) .Build(); - private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") + // quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. + private readonly MinioContainer _minio = new MinioBuilder("quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z") .WithUsername(MinioAccessKey) .WithPassword(MinioSecretKey) .WithAutoRemove(true) From 560111e01a619b0a7bbad892d212d54bd7524572 Mon Sep 17 00:00:00 2001 From: "Marcelo M. Maciel" <4993482+marcelo-maciel@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:45:29 -0300 Subject: [PATCH 07/19] fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. --- deploy/docker/README.md | 6 +++--- deploy/docker/docker-compose.yml | 3 ++- src/Host/FSH.Starter.AppHost/AppHost.cs | 3 +++ .../Infrastructure/MiddlewareWebApplicationFactory.cs | 3 ++- .../Infrastructure/FshWebApplicationFactory.cs | 3 ++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/deploy/docker/README.md b/deploy/docker/README.md index bcb1304593..0219164b7c 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -8,9 +8,9 @@ This brings up the full stack on a single host: | `admin` | `fsh/admin:local` | `FSH_ADMIN_PORT` (default 8081) | Operator console (nginx + React) | | `dashboard` | `fsh/dashboard:local` | `FSH_DASHBOARD_PORT` (default 8082) | Tenant dashboard (nginx + React) | | `migrator` | `fsh/dbmigrator:local` | — | One-shot: applies EF migrations + seeds the root tenant + creates the default admin user | -| `postgres` | `postgres:17-alpine` | (internal) | Identity, tenant catalog, module schemas | -| `redis` | `redis:7-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | -| `minio` | `minio/minio:latest` | (internal) | S3-compatible blob store for the Files module | +| `postgres` | `postgres:18-alpine` | (internal) | Identity, tenant catalog, module schemas | +| `redis` | `valkey/valkey:9.1.0-alpine` | (internal) | HybridCache L2, Data Protection keys, idempotency store | +| `minio` | `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` | (internal) | S3-compatible blob store for the Files module | The compose file does **not** include a reverse proxy or TLS terminator. You bring your own edge — Cloudflare Tunnel, AWS ALB, Tailscale Funnel, your existing nginx, anything that can route a TLS subdomain to a host:port on this machine. diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index d43c744f5b..9457a61232 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -54,7 +54,8 @@ services: # - "6379:6379" minio: - image: minio/minio:latest + # quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. + image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z container_name: fsh-minio restart: unless-stopped command: ["server", "/data", "--console-address", ":9001"] diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs index e7a70abd05..fb3506ab42 100644 --- a/src/Host/FSH.Starter.AppHost/AppHost.cs +++ b/src/Host/FSH.Starter.AppHost/AppHost.cs @@ -52,7 +52,10 @@ var minioUser = builder.AddParameter("minio-user", "minioadmin"); var minioPassword = builder.AddParameter("minio-password", "minioadmin", secret: true); +// quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. var minio = builder.AddContainer("minio", "minio/minio") + .WithImageRegistry("quay.io") + .WithImageTag("RELEASE.2025-09-07T16-13-09Z") .WithArgs("server", "/data", "--console-address", ":9001") .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console") diff --git a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs index 4c2939c454..e8b7898023 100644 --- a/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs +++ b/src/Tests/Integration.Middleware.Tests/Infrastructure/MiddlewareWebApplicationFactory.cs @@ -55,7 +55,8 @@ public sealed class MiddlewareWebApplicationFactory : WebApplicationFactory, I .WithCleanUp(true) .Build(); - private readonly MinioContainer _minio = new MinioBuilder("minio/minio:latest") + // quay.io: minio/minio is gone from Docker Hub. Tag pinned; quay stopped moving :latest. + private readonly MinioContainer _minio = new MinioBuilder("quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z") .WithUsername(MinioAccessKey) .WithPassword(MinioSecretKey) .WithAutoRemove(true) From 68ba4a99a3059c1b10dede9a353f675a31f521de Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:30:39 -0300 Subject: [PATCH 08/19] fix(identity): treat a blank profile locale as absent, not as a value UpdateUserCommandValidator guards its allow-list rule with .When(!IsNullOrWhiteSpace), so "" never reaches the allow-list. The service disagreed: `if (locale is not null)` wrote the empty string straight onto the user, so a body carrying locale: "" silently wiped a language the user had chosen. A form that serialises an untouched locale field as "" clears the preference on every unrelated save, and the allow-list never sees the value that got stored. Aligns the service with the validator's reading. The integration-test UserDto mirror gains Locale, which is why no existing test caught this. Gates: the new integration test fails on the previous code (Shouldly: dto.Locale) and passes after; UserProfileTests 8/8, Identity.Tests 330/330. --- .../Services/UserProfileService.cs | 10 +++-- .../Integration.Tests/Infrastructure/Dtos.cs | 1 + .../Tests/Users/UserProfileTests.cs | 37 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index d5e45edafc..746e67faca 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -111,9 +111,13 @@ public async Task UpdateAsync(string userId, string firstName, string lastName, user.FirstName = firstName; user.LastName = lastName; - // Null locale means "not provided by this update" — preserve the existing value so a - // text-only profile edit never clears a language the user already chose. - if (locale is not null) + // An absent locale means "not provided by this update" — preserve the existing value so a + // text-only profile edit never clears a language the user already chose. Blank counts as + // absent, matching the validator: its allow-list rule is guarded by + // .When(!IsNullOrWhiteSpace), so "" never reaches the allow-list and must not reach the + // user either. A form that serialises its untouched locale field as "" would otherwise + // wipe the preference on every unrelated save. + if (!string.IsNullOrWhiteSpace(locale)) { user.Locale = locale; } diff --git a/src/Tests/Integration.Tests/Infrastructure/Dtos.cs b/src/Tests/Integration.Tests/Infrastructure/Dtos.cs index f88a09ab2a..515ee0e933 100644 --- a/src/Tests/Integration.Tests/Infrastructure/Dtos.cs +++ b/src/Tests/Integration.Tests/Infrastructure/Dtos.cs @@ -31,6 +31,7 @@ public sealed class UserDto public bool EmailConfirmed { get; set; } public string? PhoneNumber { get; set; } public string? ImageUrl { get; set; } + public string? Locale { get; set; } } public sealed class RoleDto diff --git a/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs b/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs index f999e85300..83c7973375 100644 --- a/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs +++ b/src/Tests/Integration.Tests/Tests/Users/UserProfileTests.cs @@ -111,6 +111,43 @@ public async Task UpdateProfile_Should_Return400_When_PhoneNumberExceedsMaxLengt response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } + [Fact] + public async Task UpdateProfile_Should_KeepChosenLocale_When_BodyCarriesAnEmptyLocale() + { + // Arrange — the validator reads an empty locale as "not provided" (its rule is + // guarded by .When(!IsNullOrWhiteSpace)), so an edit that serialises the field as + // "" must leave the language the user already picked alone. + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var user = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, adminClient, "upd-locale"); + using var userClient = await _auth.CreateAuthenticatedClientAsync(user.Email, user.Password); + + var chosen = await userClient.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/profile", new + { + firstName = "Ana", + lastName = "Souza", + locale = "pt-BR" + }); + chosen.StatusCode.ShouldBe(HttpStatusCode.OK); + + // Act — a text-only edit whose form serialises the untouched locale field as "". + var response = await userClient.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/profile", new + { + firstName = "Ana Maria", + lastName = "Souza", + locale = "" + }); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var profile = await userClient.GetAsync($"{TestConstants.IdentityBasePath}/profile"); + var dto = await profile.DeserializeAsync(); + dto.FirstName.ShouldBe("Ana Maria"); + dto.Locale.ShouldBe("pt-BR"); + } + #endregion #region SetProfileImage (PUT /profile/image) From 8dd8121097a5b2ca814cdbb4e3cb17b8d67332a5 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:37:42 -0300 Subject: [PATCH 09/19] test(identity): pin the UI culture so validator assertions stop depending on the machine The localized validator messages resolve against CurrentUICulture, so the eleven tests that assert the exact English string pass on an English machine and fail on any other. On a pt-BR workstation the suite reports 11 failures for code that is correct; CI, being en-US, never sees it. Pinning the culture once for the assembly keeps the assertions as they read and makes the result a property of the code rather than of the developer's OS. --- .../Identity.Tests/Support/TestUiCulture.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/Tests/Identity.Tests/Support/TestUiCulture.cs diff --git a/src/Tests/Identity.Tests/Support/TestUiCulture.cs b/src/Tests/Identity.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..54b69f1b47 --- /dev/null +++ b/src/Tests/Identity.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Identity.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} From 06abc19232cba8fe0fb666ab39be8ad6352ffd9a Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:39:44 -0300 Subject: [PATCH 10/19] test(localization): pin the UI culture in the three remaining assemblies The earlier fix landed in Identity.Tests only, and the same defect was still in Catalog.Tests, Generic.Tests and Webhooks.Tests: a real IStringLocalizer resolves the message against CurrentUICulture while the assertion spells out the English string, so nine tests fail on a pt-BR workstation and pass on an en-US runner. Catalog 70/70, Generic 43/43, Webhooks 71/71 on a pt-BR machine, where they were 69/1, 37/6 and 69/2. --- .../Catalog.Tests/Support/TestUiCulture.cs | 26 +++++++++++++++++++ .../Generic.Tests/Support/TestUiCulture.cs | 26 +++++++++++++++++++ .../Webhooks.Tests/Support/TestUiCulture.cs | 26 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 src/Tests/Catalog.Tests/Support/TestUiCulture.cs create mode 100644 src/Tests/Generic.Tests/Support/TestUiCulture.cs create mode 100644 src/Tests/Webhooks.Tests/Support/TestUiCulture.cs diff --git a/src/Tests/Catalog.Tests/Support/TestUiCulture.cs b/src/Tests/Catalog.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..737f745999 --- /dev/null +++ b/src/Tests/Catalog.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Catalog.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} diff --git a/src/Tests/Generic.Tests/Support/TestUiCulture.cs b/src/Tests/Generic.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..824b0ec9a7 --- /dev/null +++ b/src/Tests/Generic.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Generic.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} diff --git a/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs b/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs new file mode 100644 index 0000000000..685290eefb --- /dev/null +++ b/src/Tests/Webhooks.Tests/Support/TestUiCulture.cs @@ -0,0 +1,26 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Webhooks.Tests.Support; + +internal static class TestUiCulture +{ + /// + /// Pins the UI culture for the whole test assembly. + /// + /// + /// The validator tests assert the exact English message, and those messages now come from the + /// embedded resx, which resolves against . Left to the + /// ambient value, the suite asserts a property of the developer's operating system rather than of + /// the code: green on an English machine, eleven failures on a pt-BR one, and green again on CI. + /// Pinning here rather than in each test class keeps the assertions readable and covers every + /// class that compares a localized string. + /// + [ModuleInitializer] + internal static void Pin() + { + var english = CultureInfo.GetCultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = english; + CultureInfo.DefaultThreadCurrentUICulture = english; + } +} From a519a2646f39d94116400f6f8067b8b0bed71e22 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:47:25 -0300 Subject: [PATCH 11/19] fix(migrations): order AddUserLocale after the migrations already on main The migration was generated as 20260720062947, which sorts *before* 20260807063015_DropIdentityOutbox that has since landed on main. The history was therefore inconsistent with itself: DropIdentityOutbox's designer snapshot, the later of the two by name, knows nothing about the Locale column the earlier one adds, so anything generated on top of that snapshot would try to add it again. Regenerated against main's snapshot rather than hand-edited: the two files were deleted, the snapshot restored from origin/main, and `dotnet ef migrations add AddUserLocale` re-run. The Up/Down bodies are unchanged (AddColumn Locale, varchar(10), nullable) and the resulting model snapshot is byte-identical to the one already committed (`git diff HEAD` empty), so this is purely a reordering. --- ... 20260918053951_AddUserLocale.Designer.cs} | 74 +------------------ ...ale.cs => 20260918053951_AddUserLocale.cs} | 0 2 files changed, 1 insertion(+), 73 deletions(-) rename src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/{20260720062947_AddUserLocale.Designer.cs => 20260918053951_AddUserLocale.Designer.cs} (91%) rename src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/{20260720062947_AddUserLocale.cs => 20260918053951_AddUserLocale.cs} (100%) diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.Designer.cs similarity index 91% rename from src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs rename to src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.Designer.cs index 38997b0597..329e6c67ed 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.Designer.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.Designer.cs @@ -12,7 +12,7 @@ namespace FSH.Starter.Migrations.PostgreSQL.Identity { [DbContext(typeof(IdentityDbContext))] - [Migration("20260720062947_AddUserLocale")] + [Migration("20260918053951_AddUserLocale")] partial class AddUserLocale { /// @@ -25,78 +25,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("FSH.Framework.Eventing.Inbox.InboxMessage", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("HandlerName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("character varying(512)"); - - b.Property("ProcessedOnUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("TenantId") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.HasKey("Id", "HandlerName"); - - b.ToTable("InboxMessages", "identity"); - }); - - modelBuilder.Entity("FSH.Framework.Eventing.Outbox.OutboxMessage", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CorrelationId") - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("CreatedOnUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDead") - .HasColumnType("boolean"); - - b.Property("LastError") - .HasColumnType("text"); - - b.Property("NextRetryAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Payload") - .IsRequired() - .HasColumnType("text"); - - b.Property("ProcessedOnUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("RetryCount") - .HasColumnType("integer"); - - b.Property("TenantId") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("Type") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("character varying(512)"); - - b.HasKey("Id"); - - b.ToTable("OutboxMessages", "identity"); - }); - modelBuilder.Entity("FSH.Modules.Identity.Domain.FshRole", b => { b.Property("Id") diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.cs similarity index 100% rename from src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260720062947_AddUserLocale.cs rename to src/Host/FSH.Starter.Migrations.PostgreSQL/Identity/20260918053951_AddUserLocale.cs From 9fdc4692bb2d97e055b95c78c3a95c88e2229b10 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:47:42 -0300 Subject: [PATCH 12/19] test(framework): gate placeholder parity between the resx catalogs Matching key sets were the only gate, and they do not catch a translation that drops {0} or renumbers it: the argument is either swallowed or the message throws FormatException at the point it is built, and neither shows up as a missing key. The new assertion compares the placeholder index set per key, ignoring alignment and format specifier ({0,-10}, {0:N2} are the same argument). Verified by mutation: collapsing "{0}/{1} bytes" to "{0} bytes" in the pt-BR catalog turns it red. --- .../SharedResourcesKeyParityTests.cs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs index c415d51350..25838684c5 100644 --- a/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs +++ b/src/Tests/Framework.Tests/Localization/SharedResourcesKeyParityTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; namespace Framework.Tests.Localization; @@ -8,7 +9,10 @@ namespace Framework.Tests.Localization; // asserts identical key sets. public sealed class SharedResourcesKeyParityTests { - private static List KeysFor(string culture) + private static List KeysFor(string culture) => + EntriesFor(culture).Select(e => e.Key).ToList(); + + private static Dictionary EntriesFor(string culture) { var localizer = SharedResourcesLocalizerFactory.Create(); var previous = CultureInfo.CurrentUICulture; @@ -18,8 +22,7 @@ private static List KeysFor(string culture) ? CultureInfo.InvariantCulture : new CultureInfo(culture); return localizer.GetAllStrings(includeParentCultures: false) - .Select(s => s.Name) - .ToList(); + .ToDictionary(s => s.Name, s => s.Value, StringComparer.Ordinal); } finally { @@ -27,6 +30,17 @@ private static List KeysFor(string culture) } } + // {0}, {1:N2}, {0,-10} — the index is what has to match; alignment and format do not. + private static readonly Regex PlaceholderPattern = new(@"\{(\d+)(?:[,:][^}]*)?\}", RegexOptions.Compiled); + + private static string PlaceholderSignature(string value) => + string.Join( + ",", + PlaceholderPattern.Matches(value) + .Select(m => m.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .OrderBy(i => int.Parse(i, CultureInfo.InvariantCulture))); + [Fact] public void Neutral_and_ptBR_catalogs_have_matching_keys() { @@ -37,4 +51,28 @@ public void Neutral_and_ptBR_catalogs_have_matching_keys() pt.OrderBy(k => k, StringComparer.Ordinal) .ShouldBe(neutral.OrderBy(k => k, StringComparer.Ordinal)); } + + // Matching keys are not enough. A translation that drops {0}, or renumbers it, either + // swallows the argument or throws FormatException at the point the message is built — + // and neither shows up as a missing key. + [Fact] + public void Neutral_and_ptBR_messages_take_the_same_arguments() + { + var neutral = EntriesFor(string.Empty); + var pt = EntriesFor("pt-BR"); + + var divergent = neutral + .Where(entry => pt.ContainsKey(entry.Key)) + .Select(entry => new + { + entry.Key, + Neutral = PlaceholderSignature(entry.Value), + PtBR = PlaceholderSignature(pt[entry.Key]), + }) + .Where(x => !string.Equals(x.Neutral, x.PtBR, StringComparison.Ordinal)) + .Select(x => $"{x.Key}: neutral [{x.Neutral}] vs pt-BR [{x.PtBR}]") + .ToList(); + + divergent.ShouldBeEmpty(); + } } From c0a22443fb583e44346eb26b0b0232566ef00c01 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:47:59 -0300 Subject: [PATCH 13/19] refactor(framework): freeze the supported-culture whitelist SupportedCultures.Tags was a public static string[], so the whitelist that a validator, a culture provider and the request-localization setup all trust was writable by any caller holding a reference. FrozenSet with an ordinal comparer keeps the exact matching semantics (a wrong-case tag is still rejected) and makes the set immutable. --- src/BuildingBlocks/Core/Localization/SupportedCultures.cs | 8 +++++++- .../Web/Localization/LocalizationExtensions.cs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/BuildingBlocks/Core/Localization/SupportedCultures.cs b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs index 6b00f20e3a..e462c4f775 100644 --- a/src/BuildingBlocks/Core/Localization/SupportedCultures.cs +++ b/src/BuildingBlocks/Core/Localization/SupportedCultures.cs @@ -1,3 +1,5 @@ +using System.Collections.Frozen; + namespace FSH.Framework.Core.Localization; /// Canonical set of cultures the platform supports for user-facing localization. @@ -13,6 +15,10 @@ public static class SupportedCultures /// pt, or for an unsupported variant like pt-PT, therefore resolves to /// rather than being silently served Brazilian strings. Adding a language /// means adding its specific tag here plus a *.{tag}.resx per catalog. + /// Frozen rather than an array: a public static array is writable by any caller, and the + /// whitelist a validator and a culture provider both trust cannot be a mutable global. + /// Ordinal on purpose — a wrong-case tag is a client bug, not a variant. /// - public static readonly string[] Tags = ["en-US", "pt-BR"]; + public static readonly FrozenSet Tags = + new[] { "en-US", "pt-BR" }.ToFrozenSet(StringComparer.Ordinal); } diff --git a/src/BuildingBlocks/Web/Localization/LocalizationExtensions.cs b/src/BuildingBlocks/Web/Localization/LocalizationExtensions.cs index 6010210de7..1d47bf8052 100644 --- a/src/BuildingBlocks/Web/Localization/LocalizationExtensions.cs +++ b/src/BuildingBlocks/Web/Localization/LocalizationExtensions.cs @@ -47,7 +47,7 @@ public static IServiceCollection AddHeroLocalization(this IServiceCollection ser // React apps already format at the presentation layer. See #1344 review. o.DefaultRequestCulture = new RequestCulture(CultureInfo.InvariantCulture, new CultureInfo(defaultCulture)); o.SupportedCultures = null; - o.AddSupportedUICultures(SupportedCultures.Tags); + o.AddSupportedUICultures([.. SupportedCultures.Tags]); o.ApplyCurrentCultureToResponseHeaders = true; // Default order is [Query(0), Cookie(1), AcceptLanguage(2)]. Drop the cookie provider by From f18b072d9d93f0231ed6a95ba3b2b25967de1949 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:48:19 -0300 Subject: [PATCH 14/19] feat(identity): localize the 401 challenge body The 401 that JwtBearer's OnChallenge writes is the one error response the global exception handler never sees, so it stayed English while every other error was negotiated: a pt-BR reader got "Authentication is required to access this resource." in the middle of an otherwise translated app. It resolves IStringLocalizer per request. That works because of the pipeline order this PR already relies on: UseRequestLocalization sits ahead of UseAuthorization, which is where the challenge is emitted, so the negotiated UI culture is in place by then. Error.AuthenticationRequired is new; the title reuses Error.Unauthorized. ChallengeLocalizationTests pins both the body and that ordering, hitting a protected endpoint with and without Accept-Language. --- .../Localization/SharedResources.pt-BR.resx | 3 + .../Core/Localization/SharedResources.resx | 3 + .../Jwt/ConfigureJwtBearerOptions.cs | 12 +++- .../Tests/ChallengeLocalizationTests.cs | 71 +++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/Tests/Integration.Middleware.Tests/Tests/ChallengeLocalizationTests.cs diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx index f9d387c648..71374e1381 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.pt-BR.resx @@ -67,6 +67,9 @@ Não autorizado + + É necessário autenticar-se para acessar este recurso. + Não encontrado diff --git a/src/BuildingBlocks/Core/Localization/SharedResources.resx b/src/BuildingBlocks/Core/Localization/SharedResources.resx index 742a6022ed..7e1ebe5b2e 100644 --- a/src/BuildingBlocks/Core/Localization/SharedResources.resx +++ b/src/BuildingBlocks/Core/Localization/SharedResources.resx @@ -67,6 +67,9 @@ Unauthorized + + Authentication is required to access this resource. + Not Found diff --git a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs index 76919c432e..849c04cef3 100644 --- a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs @@ -1,4 +1,5 @@ using FSH.Framework.Core.Exceptions; +using FSH.Framework.Core.Localization; using FSH.Framework.Shared.Constants; using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -8,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; @@ -99,14 +101,20 @@ public void Configure(string? name, JwtBearerOptions options) // from "no token at all" — both produce 401 but for very different reasons. bool hadAuthHeader = !string.IsNullOrEmpty(context.HttpContext.Request.Headers.Authorization); + // Resolved per request: UseRequestLocalization runs ahead of UseAuthorization, + // which is where this challenge is emitted, so the negotiated UI culture is already + // in place and this 401 reads in the same language as every other error. + var localizer = context.HttpContext.RequestServices + .GetRequiredService>(); + // RFC 9457 ProblemDetails — matches the contract the rest of the API uses // for error responses (via the global exception handler). var problem = new ProblemDetails { Type = "https://datatracker.ietf.org/doc/html/rfc7235#section-3.1", - Title = "Unauthorized", + Title = localizer["Error.Unauthorized"], Status = StatusCodes.Status401Unauthorized, - Detail = "Authentication is required to access this resource.", + Detail = localizer["Error.AuthenticationRequired"], Instance = context.HttpContext.Request.Path, }; diff --git a/src/Tests/Integration.Middleware.Tests/Tests/ChallengeLocalizationTests.cs b/src/Tests/Integration.Middleware.Tests/Tests/ChallengeLocalizationTests.cs new file mode 100644 index 0000000000..9de3da1aa2 --- /dev/null +++ b/src/Tests/Integration.Middleware.Tests/Tests/ChallengeLocalizationTests.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Integration.Middleware.Tests.Infrastructure; + +namespace Integration.Middleware.Tests.Tests; + +/// +/// The 401 body is written by JwtBearer's OnChallenge, not by the global exception handler, so it +/// is the one error response that could stay English while every other one is negotiated. This +/// pins it to the request's culture. It also pins the ordering the localization depends on: +/// UseRequestLocalization sits ahead of UseAuthorization, where the challenge is emitted. +/// +[Collection(MiddlewareCollectionDefinition.Name)] +public sealed class ChallengeLocalizationTests +{ + private readonly MiddlewareWebApplicationFactory _factory; + + public ChallengeLocalizationTests(MiddlewareWebApplicationFactory factory) + { + _factory = factory; + } + + private static async Task<(HttpStatusCode Status, string Title, string Detail)> ChallengeAsync( + HttpClient client, + string? acceptLanguage) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/identity/profile"); + if (acceptLanguage is not null) + { + request.Headers.Add("Accept-Language", acceptLanguage); + } + + using var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + return ( + response.StatusCode, + root.GetProperty("title").GetString() ?? string.Empty, + root.GetProperty("detail").GetString() ?? string.Empty); + } + + [Fact] + public async Task Challenge_Should_ReturnPortugueseProblemDetails_When_AcceptLanguageIsPtBR() + { + // Arrange + using var client = _factory.CreateClient(); + + // Act + var (status, title, detail) = await ChallengeAsync(client, "pt-BR"); + + // Assert + status.ShouldBe(HttpStatusCode.Unauthorized); + title.ShouldBe("Não autorizado"); + detail.ShouldBe("É necessário autenticar-se para acessar este recurso."); + } + + [Fact] + public async Task Challenge_Should_ReturnEnglishProblemDetails_When_NoAcceptLanguageIsSent() + { + // Arrange + using var client = _factory.CreateClient(); + + // Act + var (status, title, detail) = await ChallengeAsync(client, acceptLanguage: null); + + // Assert + status.ShouldBe(HttpStatusCode.Unauthorized); + title.ShouldBe("Unauthorized"); + detail.ShouldBe("Authentication is required to access this resource."); + } +} From 3a6b9ad9849b1c59b2976ee033c0bbeabd3b3680 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:59:04 -0300 Subject: [PATCH 15/19] fix(localization): translate the enum arguments inside localized messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven messages take an enum as an argument, and string.Format wrote the C# member name straight into the translated sentence: a pt-BR reader got "Não é possível atribuir um chamado no status Closed", "Cota ... Quarantined", "... porque está Pending". The catalogs were fully translated and the visible result still was not. GlobalExceptionHandler now resolves an enum argument as "{EnumType}.{Member}" against the same catalog as the message. A member with no entry keeps ToString(), which is exactly what every argument does today, so nothing regresses if a new enum reaches a message before its keys do. Keys added for the five enums that get there: TicketStatus, FileAssetStatus, Visibility, TopupRequestStatus, TenantProvisioningStatus. Each module's resource test now asserts every member of its enums has an entry in both catalogs, so a new member cannot ship half-translated. Ticket.Assign carried the other half of the problem: the guard took the action as a string ("assign") and fed it to a generic key, so the verb was English no matter the culture. It has one caller, so the action moves into the key (Tickets.CannotAssignInStatus) and the argument list keeps only the status. --- .../Web/Exceptions/GlobalExceptionHandler.cs | 25 ++++++++++++- .../Localization/BillingResources.pt-BR.resx | 15 ++++++++ .../Localization/BillingResources.resx | 15 ++++++++ .../Localization/FilesResources.pt-BR.resx | 15 ++++++++ .../Localization/FilesResources.resx | 15 ++++++++ .../MultitenancyResources.pt-BR.resx | 12 ++++++ .../Localization/MultitenancyResources.resx | 12 ++++++ .../Tickets/Modules.Tickets/Domain/Ticket.cs | 10 ++--- .../Localization/TicketsResources.pt-BR.resx | 16 +++++++- .../Localization/TicketsResources.resx | 16 +++++++- .../Localization/BillingResourcesTests.cs | 22 +++++++++++ .../Localization/FilesResourcesTests.cs | 23 ++++++++++++ ...GlobalExceptionHandlerLocalizationTests.cs | 37 +++++++++++++++++++ .../MultitenancyResourcesTests.cs | 22 +++++++++++ .../Localization/TicketsResourcesTests.cs | 29 ++++++++++++++- 15 files changed, 272 insertions(+), 12 deletions(-) diff --git a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs index e585536714..8ba2db36c8 100644 --- a/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs +++ b/src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs @@ -52,7 +52,7 @@ private string LocalizeDetail(ILocalizableMessage localizable, string fallbackMe { var message = localizable.MessageArgs.Count == 0 ? moduleLocalizer[localizable.MessageKey] - : moduleLocalizer[localizable.MessageKey, localizable.MessageArgs.ToArray()]; + : moduleLocalizer[localizable.MessageKey, LocalizeArguments(localizable.MessageArgs, moduleLocalizer)]; return message.ResourceNotFound ? fallbackMessage : message.Value; } catch (FormatException) @@ -61,6 +61,29 @@ private string LocalizeDetail(ILocalizableMessage localizable, string fallbackMe } } + // An enum argument would otherwise reach the user as its C# member name, leaving a translated + // sentence ending in an English word ("um chamado no status Closed"). Each member is looked up as + // "{EnumType}.{Member}" in the same catalog as the message itself. A member with no entry keeps + // ToString(), which is what every argument does today. + private static object[] LocalizeArguments(IReadOnlyList args, IStringLocalizer localizer) + { + var localized = new object[args.Count]; + for (var i = 0; i < args.Count; i++) + { + if (args[i] is Enum member) + { + var entry = localizer[$"{member.GetType().Name}.{member}"]; + localized[i] = entry.ResourceNotFound ? member.ToString() : entry.Value; + } + else + { + localized[i] = args[i]; + } + } + + return localized; + } + // Writes the localized Detail and, when the exception carries a MessageKey, surfaces that key as a // stable machine-readable "code". Detail is prose under the request culture, so a client that needs // to branch on a specific error (a terminal state, a dedicated screen, a retry) keys off the code diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx index 1e7c1a962f..bf841aa8e3 100644 --- a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.pt-BR.resx @@ -88,4 +88,19 @@ Não é possível rejeitar a solicitação de recarga {0} porque ela está {1} (somente solicitações Pendentes podem ser rejeitadas). + + Pendente + + + Faturada + + + Concluída + + + Rejeitada + + + Cancelada + diff --git a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx index 7b6363b307..6b0efe7612 100644 --- a/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx +++ b/src/Modules/Billing/Modules.Billing/Localization/BillingResources.resx @@ -88,4 +88,19 @@ Top-up request {0} cannot be rejected because it is {1} (only Pending requests can be rejected). + + Pending + + + Invoiced + + + Completed + + + Rejected + + + Cancelled + diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx index 14feeb0afe..630d451323 100644 --- a/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.pt-BR.resx @@ -115,4 +115,19 @@ A visibilidade deve ser Pública ou Privada. + + Aguardando envio + + + Disponível + + + Em quarentena + + + Público + + + Privado + diff --git a/src/Modules/Files/Modules.Files/Localization/FilesResources.resx b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx index e0116adf4e..f4db13b156 100644 --- a/src/Modules/Files/Modules.Files/Localization/FilesResources.resx +++ b/src/Modules/Files/Modules.Files/Localization/FilesResources.resx @@ -115,4 +115,19 @@ Visibility must be Public or Private. + + Pending upload + + + Available + + + Quarantined + + + Public + + + Private + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx index 25df319c32..3f32483d63 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.pt-BR.resx @@ -142,4 +142,16 @@ DefaultElevation deve estar entre 0 e 24. + + Pendente + + + Em execução + + + Concluído + + + Falhou + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx index fc4cf7d320..1107f7d245 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/MultitenancyResources.resx @@ -142,4 +142,16 @@ DefaultElevation must be between 0 and 24. + + Pending + + + Running + + + Completed + + + Failed + diff --git a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs index b932b9dedf..cf6592f573 100644 --- a/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs +++ b/src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs @@ -88,7 +88,7 @@ public static Ticket Create( public void Assign(Guid? assigneeUserId) { - ThrowIfClosedOrResolved("assign"); + ThrowIfNotAssignable(); if (assigneeUserId == AssignedToUserId) { @@ -245,17 +245,17 @@ private void TransitionStatus(TicketStatus next) (id, ts) => new TicketStatusChangedDomainEvent(Id, previous, next, id, ts))); } - private void ThrowIfClosedOrResolved(string action) + private void ThrowIfNotAssignable() { if (Status is TicketStatus.Closed or TicketStatus.Resolved) { throw new CustomException( - $"Cannot {action} a ticket in status {Status} — reopen it first.", + $"Cannot assign a ticket in status {Status} — reopen it first.", (IEnumerable?)null, HttpStatusCode.Conflict) { - MessageKey = "Tickets.CannotActionInStatus", - MessageArgs = [action, Status], + MessageKey = "Tickets.CannotAssignInStatus", + MessageArgs = [Status], ResourceSource = typeof(TicketsResources), }; } diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx index aecc4dcec4..22bdf540b2 100644 --- a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.pt-BR.resx @@ -73,8 +73,8 @@ Um chamado fechado não aceita novos comentários. Reabra-o primeiro. - - Não é possível {0} um chamado no status {1}. Reabra-o primeiro. + + Não é possível atribuir um chamado no status {0}. Reabra-o primeiro. Não é possível publicar um comentário sem um autor autenticado. @@ -82,4 +82,16 @@ Não é possível criar um chamado sem um solicitante autenticado. + + Aberto + + + Em andamento + + + Resolvido + + + Fechado + diff --git a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx index 6c74050874..8ccde88de7 100644 --- a/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx +++ b/src/Modules/Tickets/Modules.Tickets/Localization/TicketsResources.resx @@ -73,8 +73,8 @@ A closed ticket cannot accept new comments — reopen it first. - - Cannot {0} a ticket in status {1} — reopen it first. + + Cannot assign a ticket in status {0} — reopen it first. Cannot post a comment without an authenticated author. @@ -82,4 +82,16 @@ Cannot create a ticket without an authenticated reporter. + + Open + + + In progress + + + Resolved + + + Closed + diff --git a/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs index 6387ecfd6c..c0e903a852 100644 --- a/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs +++ b/src/Tests/Billing.Tests/Localization/BillingResourcesTests.cs @@ -1,4 +1,6 @@ +using System; using System.Globalization; +using FSH.Modules.Billing.Contracts; using System.Linq; using FSH.Modules.Billing.Localization; using Microsoft.Extensions.DependencyInjection; @@ -77,4 +79,24 @@ public void Known_key_resolves_and_differs_between_en_and_pt() CultureInfo.CurrentUICulture = previous; } } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(TopupRequestStatus))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } } diff --git a/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs index f5dfbde6e1..70147a8203 100644 --- a/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs +++ b/src/Tests/Files.Tests/Localization/FilesResourcesTests.cs @@ -1,4 +1,6 @@ +using System; using System.Globalization; +using FSH.Modules.Files.Contracts.v1.DTOs; using System.Linq; using FSH.Modules.Files.Localization; using Microsoft.Extensions.DependencyInjection; @@ -77,4 +79,25 @@ public void Known_key_resolves_and_differs_between_en_and_pt() CultureInfo.CurrentUICulture = previous; } } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(FileAssetStatus))] + [InlineData(typeof(Visibility))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } } diff --git a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs index 7027fd9116..71d20ee1a9 100644 --- a/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs +++ b/src/Tests/Framework.Tests/Web/GlobalExceptionHandlerLocalizationTests.cs @@ -238,4 +238,41 @@ public async Task Untranslated_status_falls_back_to_the_exception_type_name(stri title.ShouldBe(nameof(CustomException)); } + + // The lookup is by "{EnumType}.{Member}" against the message own catalog. SharedResources ships + // no enum of its own, so these two stand in for the two outcomes: a member whose name happens to + // match an existing key (translated) and one that matches nothing (kept as the C# name). + private enum Error { Unauthorized } + + private enum Untranslated { Member } + + // Without this, a pt-BR reader gets a translated sentence ending in an English enum name: + // "um chamado no status Closed". + [Fact] + public async Task An_enum_argument_is_translated_through_the_same_catalog() + { + var exception = new CustomException("english fallback", [], HttpStatusCode.BadRequest) + { + MessageKey = "Validation.ImpersonationTakeRange", + MessageArgs = [Error.Unauthorized], + }; + + var (_, detail) = await HandleAsync(exception, "pt-BR"); + + detail.ShouldBe("O valor de Take deve estar entre 1 e Não autorizado."); + } + + [Fact] + public async Task An_enum_argument_with_no_entry_keeps_its_member_name() + { + var exception = new CustomException("english fallback", [], HttpStatusCode.BadRequest) + { + MessageKey = "Validation.ImpersonationTakeRange", + MessageArgs = [Untranslated.Member], + }; + + var (_, detail) = await HandleAsync(exception, "pt-BR"); + + detail.ShouldBe("O valor de Take deve estar entre 1 e Member."); + } } diff --git a/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs index 1f360adce3..d5c38d6a3a 100644 --- a/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs +++ b/src/Tests/Multitenancy.Tests/Localization/MultitenancyResourcesTests.cs @@ -1,4 +1,6 @@ +using System; using System.Globalization; +using FSH.Modules.Multitenancy.Provisioning; using System.Linq; using FSH.Modules.Multitenancy.Localization; using Microsoft.Extensions.DependencyInjection; @@ -77,4 +79,24 @@ public void Known_key_resolves_and_differs_between_en_and_pt() CultureInfo.CurrentUICulture = previous; } } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(TenantProvisioningStatus))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } } diff --git a/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs index 193ab6f73a..6ff30ee1c1 100644 --- a/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs +++ b/src/Tests/Tickets.Tests/Localization/TicketsResourcesTests.cs @@ -1,4 +1,6 @@ +using System; using System.Globalization; +using FSH.Modules.Tickets.Contracts.Dtos; using System.Linq; using FSH.Modules.Tickets.Localization; using Microsoft.Extensions.DependencyInjection; @@ -92,12 +94,35 @@ public void Parameterized_keys_format_args_in_both_cultures() CultureInfo.CurrentUICulture = new CultureInfo("pt-BR"); localizer["Tickets.TicketNotFound", "abc"].Value.ShouldBe("Chamado abc não encontrado."); - localizer["Tickets.OnlyResolvedCanClose", "Open"].Value - .ShouldBe("Somente um chamado resolvido pode ser fechado. O status atual é Open. Resolva-o primeiro."); + // The status argument arrives already localized: GlobalExceptionHandler resolves an enum + // argument through this same catalog before formatting, so asserting the raw "Open" here + // would pin a sentence no user ever sees. + localizer["Tickets.OnlyResolvedCanClose", localizer["TicketStatus.Open"].Value].Value + .ShouldBe("Somente um chamado resolvido pode ser fechado. O status atual é Aberto. Resolva-o primeiro."); } finally { CultureInfo.CurrentUICulture = previous; } } + + // An enum handed to a message as an argument is looked up as "{EnumType}.{Member}" by + // GlobalExceptionHandler. A member with no entry falls back to its C# name, which puts an + // English word inside an otherwise translated sentence, so every member needs both entries. + [Theory] + [InlineData(typeof(TicketStatus))] + public void Every_enum_member_that_reaches_a_message_is_translated(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + var neutral = KeysFor(string.Empty); + var pt = KeysFor("pt-BR"); + + foreach (var member in Enum.GetNames(enumType)) + { + var key = $"{enumType.Name}.{member}"; + neutral.ShouldContain(key); + pt.ShouldContain(key); + } + } } From 182d022dcad8352fa753d26a925eb9c65c39cc92 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:04:57 -0300 Subject: [PATCH 16/19] test(architecture): pin the premise Audit.RealExceptionType depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged the single `BaseType` step in Audit.RealExceptionType as fragile: a subclass of a localization wrapper would report the wrapper as its type, and one BCL exception would then appear under two names in the exceptionType filter. That cannot happen today — both wrappers are sealed — so the walk is not worth writing. The premise is what needs protecting, so it is now a gate: every ILocalizableMessage that is not a CustomException must be sealed. Verified by mutation: dropping `sealed` from LocalizedKeyNotFoundException turns it red. --- .../LocalizedExceptionShapeTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs diff --git a/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs b/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs new file mode 100644 index 0000000000..270f67387f --- /dev/null +++ b/src/Tests/Architecture.Tests/LocalizedExceptionShapeTests.cs @@ -0,0 +1,49 @@ +using FSH.Framework.Core.Exceptions; +using Shouldly; +using System.Reflection; +using Xunit; + +namespace Architecture.Tests; + +/// +/// Audit.RealExceptionType reports a localization wrapper as its base type, so an audit query +/// filtering on exceptionType keeps matching the BCL exception after an endpoint is localized. +/// That single step up is only correct while the wrappers are leaves: a subclass of one would +/// report the wrapper itself, and the same BCL exception would show up under two names. +/// The rule is cheaper to enforce than the walk is to write, so it is enforced here. +/// +public sealed class LocalizedExceptionShapeTests +{ + [Fact] + public void Every_localization_wrapper_around_a_BCL_exception_is_sealed() + { + var assemblies = ModuleAssemblyDiscovery.GetModuleAssemblies() + .Append(typeof(ILocalizableMessage).Assembly) + .Distinct() + .ToArray(); + + var offenders = assemblies + .SelectMany(SafeGetTypes) + .Where(t => t.IsClass && !t.IsAbstract) + .Where(typeof(ILocalizableMessage).IsAssignableFrom) + // CustomException is ours, not a wrapper around a BCL type, and is meant to be derived from. + .Where(t => !typeof(CustomException).IsAssignableFrom(t)) + .Where(t => !t.IsSealed) + .Select(t => t.FullName!) + .ToList(); + + offenders.ShouldBeEmpty(); + } + + private static IEnumerable SafeGetTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + } +} From d86967d06a40158e70cc296a78d260eb50d051a3 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:29:29 -0300 Subject: [PATCH 17/19] fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. --- deploy/docker/docker-compose.yml | 3 ++- src/Host/FSH.Starter.AppHost/AppHost.cs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 9457a61232..dea9a817ff 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -80,7 +80,8 @@ services: # policy is set — objects are served via the API / presigned URLs, not a # public bucket. minio-init: - image: minio/mc:latest + # quay.io: minio/mc is gone from Docker Hub too. Tag pinned; quay stopped moving :latest. + image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z container_name: fsh-minio-init restart: "no" depends_on: diff --git a/src/Host/FSH.Starter.AppHost/AppHost.cs b/src/Host/FSH.Starter.AppHost/AppHost.cs index fb3506ab42..4fc689599d 100644 --- a/src/Host/FSH.Starter.AppHost/AppHost.cs +++ b/src/Host/FSH.Starter.AppHost/AppHost.cs @@ -76,6 +76,8 @@ """).ReplaceLineEndings("\n"); var minioInit = builder.AddContainer("minio-init", "minio/mc") + .WithImageRegistry("quay.io") + .WithImageTag("RELEASE.2025-08-13T08-35-41Z") .WithEntrypoint("/bin/sh") .WithArgs("-c", minioInitScript) .WithEnvironment("MC_USER", minioUser) From 88c00527f1d7ed56376d147e15738f7ce6e3beef Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 04:35:16 -0300 Subject: [PATCH 18/19] build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). --- src/Directory.Packages.props | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 89162470ad..854deb9530 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -145,12 +145,5 @@ AccessViolation). Transitive pinning is enabled, so this entry alone bumps it. Remove once the SignalR backplane package depends on a patched version itself. --> - - \ No newline at end of file From a4dfa829c61e667e2a23f3594ae041480a8e2715 Mon Sep 17 00:00:00 2001 From: "Marcelo M. M." <4993482+marcelo-maciel@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:39:18 -0300 Subject: [PATCH 19/19] ci: run Tickets.Tests in the unit-test job This PR adds `Tickets.Tests` and registers it in `FSH.Starter.slnx`, but the unit-test job enumerates test projects by name rather than running the solution. The slnx entry puts the project in the build and nowhere else: every run on this branch has been green without executing one of its four tests. A gate CI never runs is not a gate. Verified locally before wiring it in: `dotnet test src/Tests/Tickets.Tests` passes 4/4 on this head. --- .github/workflows/backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index dc5e395f7c..8c6d59f36f 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -96,7 +96,7 @@ jobs: - name: Run unit tests with coverage run: | - for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Webhooks; do + for proj in Architecture Auditing Caching Generic Identity Multitenancy Billing Catalog Chat Files Framework Tickets Webhooks; do echo "::group::${proj}.Tests" dotnet test "src/Tests/${proj}.Tests" -c Release --no-build \ --collect:"XPlat Code Coverage" --settings coverage.runsettings \