diff --git a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs index 2093227e72..4cea494622 100644 --- a/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs +++ b/src/ServiceControl.AcceptanceTests/Security/OpenIdConnect/When_my_routes_are_requested.cs @@ -13,7 +13,7 @@ namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect; using ServiceControl.Infrastructure.Auth; /// -/// my/routes returns the API routes the current token may call, as { method, urlTemplate } entries. +/// my/routes returns the API routes the current token may call, as { method, url_template } entries. /// It is the per-instance authorization contract ServicePulse consumes: it gates UI on routes it /// already calls rather than on the server's internal permission vocabulary. /// diff --git a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs index 28a6d36ec1..9d7194bb4a 100644 --- a/src/ServiceControl.Hosting/Auth/MyRoutesController.cs +++ b/src/ServiceControl.Hosting/Auth/MyRoutesController.cs @@ -8,7 +8,7 @@ namespace ServiceControl.Hosting.Auth; using ServiceControl.Infrastructure.Auth; /// -/// Returns the API routes the current token may call, as { method, urlTemplate } entries. +/// Returns the API routes the current token may call, as { method, url_template } entries. /// This is the per-instance authorization contract for clients (ServicePulse): each instance reports /// only the routes it serves, so a client matches its outgoing request against the allowed set without /// ever learning the server's internal permission vocabulary. The endpoint is the bootstrap of that diff --git a/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs new file mode 100644 index 0000000000..664f56b190 --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/Auth/RouteManifestEntrySerializationTests.cs @@ -0,0 +1,27 @@ +#nullable enable +namespace ServiceControl.Infrastructure.Tests.Auth; + +using System.Text.Json; +using NUnit.Framework; +using ServiceControl.Infrastructure.Auth; + +[TestFixture] +class RouteManifestEntrySerializationTests +{ + // The my/routes manifest must have ONE wire shape across instances. The Primary instance + // serializes snake_case and the Monitoring instance camelCase, so RouteManifestEntry pins its + // field names with [JsonPropertyName]. This test guards that contract: even under a camelCase + // policy (as on the Monitoring host) the emitted names stay snake_case, so a client merging both + // instances never silently drops the differently-cased entries. + [Test] + public void Emits_snake_case_field_names_even_under_a_camelCase_policy() + { + var json = JsonSerializer.Serialize( + new RouteManifestEntry("GET", "/api/errors"), + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + Assert.That(json, Does.Contain("\"method\"")); + Assert.That(json, Does.Contain("\"url_template\"")); + Assert.That(json, Does.Not.Contain("urlTemplate")); + } +} diff --git a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs index c697a056f2..d583803f62 100644 --- a/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs +++ b/src/ServiceControl.Infrastructure/Auth/RouteManifest.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Infrastructure.Auth; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; /// @@ -28,8 +29,16 @@ public static string Normalize(string rawTemplate) /// A route the server hosts, with the authorization metadata read from its endpoint. public sealed record RouteAuthInfo(string Method, string UrlTemplate, string? RequiredPermission, bool AllowAnonymous); -/// A single allowed-route entry returned to the client. -public sealed record RouteManifestEntry(string Method, string UrlTemplate); +/// +/// A single allowed-route entry returned to the client. The JSON field names are pinned with +/// so the manifest has one stable shape regardless of each host's +/// global JSON naming policy (the Primary instance serializes snake_case, the Monitoring instance +/// camelCase). Without this the same contract would emit url_template on one instance and +/// urlTemplate on another, and clients that merge both would silently drop half the routes. +/// +public sealed record RouteManifestEntry( + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("url_template")] string UrlTemplate); /// /// Projects the route table down to the entries a caller may invoke. A route is included when it is