Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to the MMCA.Common packages are documented here. The format
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/)
and are derived from git tags by MinVer (see [the published versioning policy](https://ivanball.github.io/docs/guides/common-VERSIONING.html)).

## [Unreleased]

### Added

- `SpanishAccentTestsBase` (`MMCA.Common.Testing.Architecture`, flat `Bases` namespace): a fitness test that scans every `*.es.resx` under the subclass's `ResourceRoot` (bin, obj, node_modules and .git skipped) and fails on a string value containing a known unaccented spelling ("codigo", "sesion", "aplicacion" and the rest of `UnaccentedWords`), matched as a whole word, listing each hit as `file:key: words`. Override `UnaccentedWords` to extend the list (`[.. base.UnaccentedWords, "x"]`), `AllowedEntries` to accept an intended spelling by its `relative/path.es.resx:Key`, and `MinimumResourceFileCount` (default 1) so a wrong root fails instead of passing on zero files. MMCA.Common runs it over its own `Source/`.

### Fixed

- The server-side `"APIClient"` handler from `AddCommonServerTokenStorage()` (renamed `BrowserOriginHandler`, internal) also forwards the visitor's `User-Agent`, replacing the host's empty one, so a sign-in or registration made on the Blazor Server path records the browser's device on the signed-in devices page instead of "Unrecognized device", matching the cookie-session refresh. A blank user-agent is not forwarded.
- The per-IP registration rate limit trips over the hybrid cache. `LoginProtectionService.CheckRegistrationRateLimitAsync` reads the counter with `ICacheService.GetFromSharedStoreAsync`, since `IncrementAsync` writes the shared store only; it read through `GetAsync`, which `HybridCacheService` answered from a 30-second in-process copy, so the count it saw stayed at its first value and a burst from one IP was never refused. The fail-open behavior on a cache outage is unchanged. The lockout check is unchanged: its flag is written with `SetAsync`, which updates both tiers.
- `DataGridListPageBase` shows "Loading cancelled." only when the cancelled load is still the current one and the page is not disposed. A load superseded by a newer one (a re-sort, a filter change, a search) and a load cut off by leaving the page used to toast it too; a user's Cancel still toasts once.
- The gateway downstream health checks from `AddGatewayDownstreamHealthChecks` stop flapping on an HTTP/1.1 head. Each check is one instance per downstream for the life of the provider (a keyed singleton the registration factory resolves), so the HTTP version latched on the first poll survives to the next; the health-check service rebuilt the check on every poll, so every poll renegotiated. The `gateway-downstream-*` probe clients also drop the resilience handler that `AddServiceDefaults()` puts on every client (`RemoveAllResilienceHandlers`): its retry backoff spent the two-second probe budget on the refused HTTP/2 attempt, so the HTTP/1.1 fallback never ran and a healthy downstream read Unhealthy.
- Spanish role-administration strings (`RoleAdminListResources.es.resx`, `RoleAdminEditResources.es.resx`) and the OAuth unexpected-completion message (`SharedResource.es.resx`) carry their accents; eight values used the unaccented spellings of "codigo", "mas", "aqui", "numero", "estan", "aplicacion", "aun" and "sesion".

## [1.225.0] - 2026-10-03

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions FACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ The ADRs live in the Website repo (`docs-src/adr/`), published at
it owns the range/count and the one-line summaries. Do not restate the `(001-NNN)` range elsewhere.

## Architecture fitness functions
- **141 test methods across 55 abstract `*TestsBase` classes**, shipped once in the
- **142 test methods across 56 abstract `*TestsBase` classes**, shipped once in the
`MMCA.Common.Testing.Architecture` package (ADR-015) and re-run as thin subclasses across all consuming
repos (Common, ADC, Store).
- MMCA.Common's own build executes **339** of them (the methods of the bases its arch-tests
- MMCA.Common's own build executes **340** of them (the methods of the bases its arch-tests
subclass, plus its Common-only direct tests, e.g. `FrameworkSanityTests`/`SpecificationFitnessTests`).

## Governance rubric
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ public async Task<Result> CheckRegistrationRateLimitAsync(string? ipAddress, Can
long registrationCount;
try
{
registrationCount = await cacheService.GetAsync<long?>(key, cancellationToken).ConfigureAwait(false) ?? 0;
// Read the counter from the shared store, never from a process-local copy: IncrementAsync
// writes the shared store only, so a hybrid cache's in-process entry would pin the first
// count it saw and the limit would never trip while that copy lived.
registrationCount = await cacheService.GetFromSharedStoreAsync<long?>(key, cancellationToken).ConfigureAwait(false) ?? 0;
}
catch (Exception ex) when (IsCacheOutage(ex, cancellationToken))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ namespace MMCA.Common.Aspire.Gateway;
/// <para>
/// Under <see cref="DownstreamProbeVersion.Auto"/> the first probe asks for HTTP/2 and, if the
/// downstream refuses the protocol, retries once as HTTP/1.1 within the same check, so one poll
/// still yields one verdict. The version that answered is latched for the life of this instance,
/// and the health-check service holds one instance per downstream, so the latch is effectively per
/// still yields one verdict. The version that answered is latched for the life of this instance.
/// The health-check service rebuilds a check from its registration factory on every poll, so
/// <c>AddGatewayDownstreamHealthChecks</c> registers each instance as a keyed singleton and the
/// factory resolves that one: there is one instance per downstream, and the latch is per
/// downstream for the life of the process. That is safe because a service cannot change the
/// protocol of its cleartext endpoint without a redeploy, and a redeploy of the topology restarts
/// this gateway too: a stale latch cannot outlive the endpoint that justified it.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Http.Resilience;

namespace MMCA.Common.Aspire.Gateway;

Expand Down Expand Up @@ -198,15 +199,44 @@ private static IServiceCollection Register(
// request, because under DownstreamProbeVersion.Auto the check discovers which
// version this downstream speaks and may send both on one poll. See
// GatewayDownstreamHealthCheckOptions.ProbeVersion.
})

// No resilience handler on the probe: AddServiceDefaults() puts the standard Polly
// pipeline on every factory client, and its one retry (about two seconds of backoff)
// lands inside the two-second probe budget. A refused HTTP/2 attempt must reach the
// check at once so it can fall back to HTTP/1.1, and a probe is re-sent on the next
// poll anyway, so a retry here only turns a healthy downstream into a timed-out one.
// This is what RemoveAllResilienceHandlers() does, written against the stable
// ConfigureAdditionalHttpMessageHandlers because that helper is still EXTEXP0001
// (experimental) in Microsoft.Extensions.Http.Resilience.
.ConfigureAdditionalHttpMessageHandlers(static (handlers, _) =>
{
for (var i = handlers.Count - 1; i >= 0; i--)
{
if (handlers[i] is ResilienceHandler)
{
handlers.RemoveAt(i);
}
}
});

healthChecks.Add(new HealthCheckRegistration(
CheckName(name),
sp => new DownstreamServiceHealthCheck(
// One check instance per downstream for the life of the provider. The health-check
// service builds the check from its registration factory on EVERY poll, so a factory
// that news one up would drop the latched HTTP version between polls and renegotiate
// each time. The singleton is keyed by the check name; the latch inside it is already
// safe under overlapping polls (Interlocked, first writer wins).
var checkName = CheckName(name);
services.AddKeyedSingleton(
checkName,
(sp, _) => new DownstreamServiceHealthCheck(
sp.GetRequiredService<IHttpClientFactory>(),
name,
clientName,
probeVersion),
probeVersion));

healthChecks.Add(new HealthCheckRegistration(
checkName,
sp => sp.GetRequiredKeyedService<DownstreamServiceHealthCheck>(checkName),
failureStatus: HealthStatus.Unhealthy,
tags: [HealthCheckTags.Ready],
timeout: ProbeTimeout));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using System.Text.RegularExpressions;

namespace MMCA.Common.Testing.Architecture;

/// <summary>
/// Spanish-localization fitness function: no Spanish resource string ships a common word with its
/// required accent or n-tilde missing (<c>codigo</c> for the word with the accented o, <c>sesion</c>,
/// <c>contrasena</c> for the word with the n-tilde, and so on). Those strings compile, render and pass
/// every functional test, so without this gate they are found by a Spanish-speaking user.
/// Authored once here and re-run as a thin subclass in each repo, which supplies the
/// <see cref="ResourceRoot"/> to scan and, when it has deliberate exceptions, its
/// <see cref="AllowedEntries"/>.
/// <para>
/// Every <c>*.es.resx</c> under the root is read, and only the text of each string
/// <c>&lt;data&gt;&lt;value&gt;</c> is checked (keys and comments are not). A word matches only as a
/// whole word, case-insensitively, so the correctly accented form never matches: an accented letter
/// is a different letter, and a longer word (a plural, a derived form) is a different word.
/// </para>
/// <para>
/// The list holds words whose unaccented spelling is almost always the mistake in UI text. Words that
/// are equally correct with and without the accent depending on meaning (<c>esta</c> as "this"
/// against the verb form, <c>solo</c>, which no longer takes an accent) are deliberately left out, and
/// plurals that drop the accent by rule (<c>sesiones</c>, <c>aplicaciones</c>) never match.
/// </para>
/// </summary>
public abstract class SpanishAccentTestsBase
{
/// <summary>
/// The unaccented spellings checked by default. Each stands for a word that requires an accent or
/// an n-tilde in the sense UI text uses it.
/// </summary>
private static readonly string[] DefaultUnaccentedWords =
[
"sesion", "codigo", "codigos", "contrasena", "contrasenas", "numero", "numeros", "maximo",
"minimo", "titulo", "direccion", "informacion", "posicion", "clasificacion", "electronico",
"electronica", "pagina", "paginas", "aqui", "mas", "todavia", "aun", "estan", "publico",
"ningun", "podra", "cerrara", "volvera", "encontro", "visito", "tenia", "aplicacion",
"limite", "puntuacion", "perdio", "confirmo",
];

/// <summary>
/// The folder to scan recursively for <c>*.es.resx</c> files, normally the repo's <c>Source</c>
/// folder under <c>ArchitectureMapBase.FindRepoRoot("&lt;Repo&gt;.slnx")</c>.
/// </summary>
protected abstract string ResourceRoot { get; }

/// <summary>
/// The unaccented spellings to reject. Defaults to the framework list; a subclass extends it with
/// <c>[.. base.UnaccentedWords, "extra"]</c> for domain vocabulary of its own.
/// </summary>
protected virtual IReadOnlyCollection<string> UnaccentedWords => DefaultUnaccentedWords;

/// <summary>
/// Intentional exceptions, each written exactly as the failure lists it:
/// <c>{path relative to the resource root, forward slashes}:{resource key}</c>. Keep it short and
/// say why next to each entry; an exception is a decision, not a way to make the gate pass.
/// </summary>
protected virtual IReadOnlyCollection<string> AllowedEntries => [];

/// <summary>
/// The fewest <c>*.es.resx</c> files the scan must find. A wrong root that finds none would
/// otherwise pass with nothing checked.
/// </summary>
protected virtual int MinimumResourceFileCount => 1;

[Fact]
public void Spanish_resources_keep_their_accents()
{
ResourceRoot.Should().NotBeNullOrWhiteSpace();
Directory.Exists(ResourceRoot).Should().BeTrue($"the resource root '{ResourceRoot}' must exist");

var files = Directory
.EnumerateFiles(ResourceRoot, "*.es.resx", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(Path.GetRelativePath(ResourceRoot, path)))
.Order(StringComparer.Ordinal)
.ToList();

files.Count.Should().BeGreaterThanOrEqualTo(
MinimumResourceFileCount,
$"the scan must find Spanish resources under '{ResourceRoot}', or it checks nothing");

var pattern = @"\b(?:" + string.Join('|', UnaccentedWords.Select(Regex.Escape)) + @")\b";
var unaccented = new Regex(
pattern,
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromSeconds(1));

var allowed = AllowedEntries.ToHashSet(StringComparer.Ordinal);
var offenders = new List<string>();

foreach (var file in files)
{
var relative = Path.GetRelativePath(ResourceRoot, file).Replace('\\', '/');

foreach (var data in XDocument.Load(file).Root?.Elements("data") ?? [])
{
// Non-string entries (file references, serialized objects) carry no UI text.
if (data.Attribute("type") is not null || data.Attribute("mimetype") is not null)
{
continue;
}

var key = (string?)data.Attribute("name") ?? string.Empty;
var value = (string?)data.Element("value") ?? string.Empty;
var entry = relative + ":" + key;
if (allowed.Contains(entry))
{
continue;
}

var words = unaccented.Matches(value)
.Select(static match => match.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (words.Count > 0)
{
offenders.Add($" - {entry}: {string.Join(", ", words)}");
}
}
}

ArchitectureAssert.NoViolations(
offenders,
"Spanish resource strings must carry their accents and n-tildes; fix the value, or add "
+ "'file:key' to AllowedEntries when the unaccented spelling is intended");
}

/// <summary>True when the path runs through build output or a tool-owned tree.</summary>
private static bool IsBuildOutput(string relativePath) =>
relativePath
.Replace('\\', '/')
.Split('/')
.Any(static segment => segment is "bin" or "obj" or "node_modules" or ".git");
}
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
#nullable enable
MMCA.Common.Testing.Architecture.SpanishAccentTestsBase
MMCA.Common.Testing.Architecture.SpanishAccentTestsBase.Spanish_resources_keep_their_accents() -> void
MMCA.Common.Testing.Architecture.SpanishAccentTestsBase.SpanishAccentTestsBase() -> void
abstract MMCA.Common.Testing.Architecture.SpanishAccentTestsBase.ResourceRoot.get -> string!
virtual MMCA.Common.Testing.Architecture.SpanishAccentTestsBase.AllowedEntries.get -> System.Collections.Generic.IReadOnlyCollection<string!>!
virtual MMCA.Common.Testing.Architecture.SpanishAccentTestsBase.MinimumResourceFileCount.get -> int
virtual MMCA.Common.Testing.Architecture.SpanishAccentTestsBase.UnaccentedWords.get -> System.Collections.Generic.IReadOnlyCollection<string!>!
17 changes: 9 additions & 8 deletions Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ public static class DependencyInjection
/// cookie plumbing from MMCA.Common.API (<c>AddServerAuthSessionCookie</c> /
/// <c>UseCookieSessionRefresh</c>) and a registered <c>ITokenRefresher</c>.
/// <para>
/// Also forwards the visitor's address on this host's server-side <c>"APIClient"</c> calls:
/// each request carries <c>X-Forwarded-For</c> set to the remote IP of the HTTP request behind
/// the render (the page request during prerender, the circuit's connection afterwards), the
/// same value the cookie-session refresh already forwards, so per-client limits such as the
/// registration rate limit key on the visitor instead of on this host. Nothing is sent when no
/// request is in scope.
/// Also forwards the visitor's origin on this host's server-side <c>"APIClient"</c> calls:
/// each request carries <c>X-Forwarded-For</c> set to the remote IP, and <c>User-Agent</c> set
/// to the browser's user-agent, of the HTTP request behind the render (the page request during
/// prerender, the circuit's connection afterwards), the same values the cookie-session refresh
/// already forwards, so per-client limits such as the registration rate limit key on the
/// visitor instead of on this host and a sign-in records the visitor's device. Nothing is sent
/// when no request is in scope.
/// </para>
/// </summary>
public IServiceCollection AddCommonServerTokenStorage()
Expand All @@ -43,8 +44,8 @@ public IServiceCollection AddCommonServerTokenStorage()

// The UI services' named client (AddUIShared); a second AddHttpClient call with the same
// name appends to that client's pipeline, whichever of the two registrations runs first.
services.AddTransient<BrowserForwardedForHandler>();
services.AddHttpClient("APIClient").AddHttpMessageHandler<BrowserForwardedForHandler>();
services.AddTransient<BrowserOriginHandler>();
services.AddHttpClient("APIClient").AddHttpMessageHandler<BrowserOriginHandler>();

return services.AddScoped<ITokenStorageService, ServerTokenStorageService>();
}
Expand Down
Loading
Loading