using System.Net; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc.Testing; namespace MeterVault.Integration.Tests.Localization; /// /// The language picker's endpoint and the culture the middleware hands each render (SDD §12, M7). /// /// /// /// A Blazor Server circuit is fixed to the culture of the request that opened it, so switching /// language cannot be an interactive state change — it is a redirect that writes a cookie and forces /// a reload. That makes /culture/set a redirector taking its target from the query string, /// which is the exact shape of an open redirect, so the off-site cases below are load-bearing. /// /// /// Nothing here touches the database: no page on these paths queries, and startup migration is off, /// so the connection string only has to parse. They run without Docker. /// /// public sealed class CultureEndpointTests : IDisposable { private const string UnusedConnection = "Host=localhost;Port=1;Database=metervault;Username=none;Password=none"; /// Renders the full layout (app bar + nav) without needing any data. private static readonly Uri LayoutOnlyPage = new("/admin/settings", UriKind.Relative); private readonly List _factories = []; [Fact] public async Task Setting_a_supported_culture_stores_the_cookie_and_returns_the_user_to_the_page() { using var client = RedirectlessClient(); using var response = await client.GetAsync( new Uri("/culture/set?culture=de&redirectUri=%2Fmeters%2F7", UriKind.Relative)); Assert.Equal(HttpStatusCode.Found, response.StatusCode); Assert.Equal("/meters/7", response.Headers.Location?.OriginalString); Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal); } [Fact] public async Task A_regional_variant_is_stored_as_the_language_we_ship() { using var client = RedirectlessClient(); using var response = await client.GetAsync( new Uri("/culture/set?culture=de-AT&redirectUri=%2F", UriKind.Relative)); Assert.Equal(HttpStatusCode.Found, response.StatusCode); Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal); } [Theory] [InlineData("fr")] [InlineData("")] [InlineData("../../etc/passwd")] public async Task A_language_we_do_not_ship_is_refused_rather_than_stored(string culture) { using var client = RedirectlessClient(); using var response = await client.GetAsync( new Uri($"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri=%2F", UriKind.Relative)); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Null(CultureCookie(response)); } [Theory] [InlineData("https://evil.example/phish")] [InlineData("//evil.example/phish")] [InlineData("/\\evil.example/phish")] [InlineData("")] public async Task An_off_site_redirect_target_lands_on_the_dashboard_instead(string redirectUri) { using var client = RedirectlessClient(); using var response = await client.GetAsync( new Uri($"/culture/set?culture=de&redirectUri={Uri.EscapeDataString(redirectUri)}", UriKind.Relative)); Assert.Equal(HttpStatusCode.Found, response.StatusCode); Assert.Equal("/", response.Headers.Location?.OriginalString); } [Fact] public async Task The_culture_cookie_decides_the_language_of_the_rendered_page() { using var client = Factory().CreateClient(); client.DefaultRequestHeaders.Add( "Cookie", CookieRequestCultureProvider.DefaultCookieName + "=" + Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de")))); var html = await RenderedTextAsync(client, LayoutOnlyPage); Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal); Assert.Contains("Übersicht", html, StringComparison.Ordinal); Assert.DoesNotContain("Overview", html, StringComparison.Ordinal); } [Fact] public async Task Without_a_cookie_the_instance_default_locale_applies() { using var client = Factory(locale: "de").CreateClient(); var html = await RenderedTextAsync(client, LayoutOnlyPage); Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal); Assert.Contains("Übersicht", html, StringComparison.Ordinal); } [Fact] public async Task English_is_the_shipped_default_when_nothing_is_configured() { using var client = Factory().CreateClient(); var html = await RenderedTextAsync(client, LayoutOnlyPage); Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal); Assert.Contains("Overview", html, StringComparison.Ordinal); } [Fact] public async Task An_unshipped_default_locale_degrades_to_english_instead_of_failing_to_boot() { using var client = Factory(locale: "fr").CreateClient(); var html = await RenderedTextAsync(client, LayoutOnlyPage); Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal); Assert.Contains("Overview", html, StringComparison.Ordinal); } [Fact] public async Task The_first_visit_pins_the_negotiated_culture_into_the_cookie() { // Otherwise a reader whose browser asks for German is served German, but opens the picker // to find English ticked — the cookie is the only thing either side agrees to read. using var client = RedirectlessClient(); client.DefaultRequestHeaders.Add("Accept-Language", "de-DE,de;q=0.9,en;q=0.8"); using var response = await client.GetAsync(LayoutOnlyPage); Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal); } public void Dispose() { foreach (var factory in _factories) { factory.Dispose(); } } /// /// Blazor entity-encodes every non-ASCII character it renders, so "Übersicht" arrives as /// "Übersicht" — decoding first is what lets these assertions read like the UI does. /// private static async Task RenderedTextAsync(HttpClient client, Uri page) => WebUtility.HtmlDecode(await client.GetStringAsync(page)); private static string? CultureCookie(HttpResponseMessage response) => response.Headers.TryGetValues("Set-Cookie", out var cookies) ? cookies.FirstOrDefault(c => c.StartsWith(CookieRequestCultureProvider.DefaultCookieName, StringComparison.Ordinal)) : null; private MeterVaultAppFactory Factory(string? locale = null) { var factory = new MeterVaultAppFactory(UnusedConnection, configureApiKey: false) { Locale = locale }; _factories.Add(factory); return factory; } private HttpClient RedirectlessClient() => Factory().CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); }