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."); + } }