diff --git a/src/ServiceControl.Federation.UnitTests/.editorconfig b/src/ServiceControl.Federation.UnitTests/.editorconfig new file mode 100644 index 0000000000..b9691251b6 --- /dev/null +++ b/src/ServiceControl.Federation.UnitTests/.editorconfig @@ -0,0 +1,7 @@ +[*.cs] + +# Justification: Application synchronization contexts don't require ConfigureAwait(false) +dotnet_diagnostic.CA2007.severity = none + +# Justification: test helpers that start in-process hosts do not need cancellation +dotnet_diagnostic.PS0018.severity = none diff --git a/src/ServiceControl.Federation.UnitTests/FederationEndToEndTests.cs b/src/ServiceControl.Federation.UnitTests/FederationEndToEndTests.cs new file mode 100644 index 0000000000..6fe0f0c93d --- /dev/null +++ b/src/ServiceControl.Federation.UnitTests/FederationEndToEndTests.cs @@ -0,0 +1,349 @@ +namespace ServiceControl.Federation.UnitTests; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Template; +using Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +/// +/// Runs the proxy against two fake members over real HTTP and checks what ServicePulse would see. +/// +[TestFixture] +[NonParallelizable] +class FederationEndToEndTests +{ + [SetUp] + public async Task SetUp() + { + memberA = await FakeMember.Start("a"); + memberB = await FakeMember.Start("b"); + (proxy, client) = await StartProxy(memberA, memberB); + } + + [TearDown] + public async Task TearDown() + { + client.Dispose(); + await proxy.DisposeAsync(); + await memberA.DisposeAsync(); + await memberB.DisposeAsync(); + } + + [Test] + public async Task Failed_message_pages_are_merged_across_members() + { + memberA.Respond("GET", "/api/errors", ctx => List(ctx, Failures("a", 0, 2, 4))); + memberB.Respond("GET", "/api/errors", ctx => List(ctx, Failures("b", 1, 3, 5))); + + var response = await client.GetAsync("api/errors?status=unresolved&page=2&per_page=2&sort=time_of_failure&direction=asc"); + var rows = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(rows.Select(r => r!["id"]!.GetValue()), Is.EqualTo(new[] { "a-2", "b-3" })); + Assert.That(response.Headers.GetValues("Total-Count").Single(), Is.EqualTo("6")); + Assert.That(memberA.Requests, Has.Some.Contains("page=1").And.Contains("per_page=4"), "each member is asked for its first page·per_page rows"); + } + } + + [Test] + public async Task Batch_commands_reach_only_the_owning_member() + { + memberA.Respond("GET", "/api/errors", ctx => List(ctx, Failures("a", 0))); + memberB.Respond("GET", "/api/errors", ctx => List(ctx, Failures("b", 1))); + memberA.Respond("POST", "/api/errors/retry", _ => Results.Accepted()); + memberB.Respond("POST", "/api/errors/retry", _ => Results.Accepted()); + + await client.GetAsync("api/errors"); + var response = await client.PostAsync("api/errors/retry", Json("[\"b-1\"]")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + Assert.That(memberB.Bodies("POST /api/errors/retry"), Is.EqualTo(new[] { "[\"b-1\"]" })); + Assert.That(memberA.Bodies("POST /api/errors/retry"), Is.Empty); + } + } + + [Test] + public async Task Unknown_ids_are_found_by_probing_members() + { + memberA.Respond("GET", "/api/errors/last/{id}", _ => Results.NotFound()); + memberB.Respond("GET", "/api/errors/last/{id}", _ => Results.Json(new { id = "b-9" })); + memberB.Respond("PATCH", "/api/errors/archive", _ => Results.Accepted()); + + var response = await client.PatchAsync("api/errors/archive", Json("[\"b-9\"]")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + Assert.That(memberB.Bodies("PATCH /api/errors/archive"), Has.Count.EqualTo(1)); + Assert.That(memberA.Requests, Has.None.StartsWith("PATCH")); + } + } + + [Test] + public async Task Root_document_points_at_the_proxy_and_separates_display_and_gating_versions() + { + memberA.Version = "6.12.0"; + memberB.Version = "6.10.1"; + memberA.Respond("GET", "/api", ctx => Results.Json(new { name = "A", my_routes_url = $"{memberA.BaseUrl}api/my/routes", license_status = "valid" })); + memberB.Respond("GET", "/api", ctx => Results.Json(new { name = "B", my_routes_url = $"{memberB.BaseUrl}api/my/routes", license_status = "valid" })); + + var response = await client.GetAsync("api"); + var body = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.Headers.GetValues("X-Particular-Version").Single(), Is.EqualTo("6.10.1"), "feature gates read the lowest member version"); + Assert.That(body["platform_health_version"]!.GetValue(), Is.EqualTo("6.12.0"), "platform health shows the lead member's own version"); + Assert.That(body["my_routes_url"]!.GetValue(), Is.EqualTo($"{client.BaseAddress}api/my/routes")); + Assert.That(body["name"]!.GetValue(), Is.EqualTo("Federation under test")); + } + } + + [Test] + public async Task Instance_names_are_qualified_so_internal_checks_match_their_own_installation() + { + static IResult Audit() => Results.Json(new[] { new { api_uri = "http://servicecontrol-audit:44444", status = "online", configuration = new { host = new { instance_name = "Particular.ServiceControl.Audit" } } } }); + memberA.Respond("GET", "/api/configuration/remotes", _ => Audit()); + memberB.Respond("GET", "/api/configuration/remotes", _ => Audit()); + memberB.Respond("GET", "/api/configuration", _ => Results.Json(new { host = new { instance_name = "Particular.ServiceControl" } })); + memberA.Respond("GET", "/api/customchecks", ctx => List(ctx, [Check("c1", internalCheck: true), Check("c2", internalCheck: false)])); + memberB.Respond("GET", "/api/customchecks", ctx => List(ctx, [])); + + var remotes = JsonNode.Parse(await client.GetStringAsync("api/configuration/remotes"))!.AsArray(); + var checks = JsonNode.Parse(await client.GetStringAsync("api/customchecks?status=fail"))!.AsArray(); + + string Name(JsonNode? node, string path) => Aggregation.JsonValues.Text(node, path)!; + + using (Assert.EnterMultipleScope()) + { + Assert.That(remotes.Select(r => Name(r, "configuration.host.instance_name")), Is.EqualTo(new[] + { + "Particular.ServiceControl.Audit (a)", + "Particular.ServiceControl.Audit (b)", + "Particular.ServiceControl (b)" + }), "audit instances with the same address in two installations stay separate"); + Assert.That(checks.Select(c => Name(c, "originating_endpoint.name")), Is.EqualTo(new[] { "Particular.ServiceControl.Audit (a)", "Sales" }), "only internal checks are renamed"); + } + } + + static JsonNode Check(string id, bool internalCheck) => new JsonObject + { + ["id"] = id, + ["internal"] = internalCheck, + ["reported_at"] = DateTime.UnixEpoch.ToString("O"), + ["originating_endpoint"] = new JsonObject { ["name"] = internalCheck ? "Particular.ServiceControl.Audit" : "Sales" } + }; + + [Test] + public async Task Monitoring_root_shows_the_lead_version_and_the_health_of_every_member() + { + memberA.Version = "6.12.0"; + memberB.Version = "6.10.1"; + memberA.Respond("GET", "/", _ => Results.Json(new { instanceType = "monitoring", version = "6.12.0" })); + memberB.Respond("GET", "/", _ => Results.Json(new { instanceType = "monitoring", version = "6.10.1" })); + + var healthy = await client.GetAsync("monitoring"); + var healthyBody = JsonNode.Parse(await healthy.Content.ReadAsStringAsync())!; + + await memberB.DisposeAsync(); + var degradedBody = JsonNode.Parse(await client.GetStringAsync("monitoring"))!; + + using (Assert.EnterMultipleScope()) + { + Assert.That(healthy.Headers.GetValues("X-Particular-Version").Single(), Is.EqualTo("6.10.1")); + Assert.That(healthyBody["platform_health_version"]!.GetValue(), Is.EqualTo("6.12.0")); + Assert.That(healthyBody["platform_health_status"]!.GetValue(), Is.EqualTo("healthy")); + Assert.That(degradedBody["platform_health_status"]!.GetValue(), Is.EqualTo("degraded"), "one Monitoring row stands for every member"); + } + } + + [Test] + public async Task Message_bodies_are_routed_by_instance_id() + { + memberA.Respond("GET", "/api/messages", ctx => List(ctx, [new JsonObject { ["id"] = "m", ["message_id"] = "m", ["body_url"] = "/messages/m/body?instance_id=audit1" }])); + memberB.Respond("GET", "/api/messages", ctx => List(ctx, [])); + memberA.Respond("GET", "/api/messages/{id}/body", ctx => Results.Text($"body from {ctx.Request.QueryString}", "text/plain")); + + var list = JsonNode.Parse(await client.GetStringAsync("api/messages"))!.AsArray(); + var bodyUrl = list[0]!["body_url"]!.GetValue(); + var body = await client.GetStringAsync("api" + bodyUrl); + + using (Assert.EnterMultipleScope()) + { + Assert.That(bodyUrl, Is.EqualTo("/messages/m/body?instance_id=a~audit1")); + Assert.That(body, Is.EqualTo("body from ?instance_id=audit1")); + } + } + + [Test] + public async Task Failure_groups_are_merged_by_id() + { + memberA.Respond("GET", "/api/recoverability/groups/{classifier}", _ => Results.Json(new[] { new { id = "g", title = "T", count = 2, last = "2024-01-01T00:00:00Z", operation_status = "None" } })); + memberB.Respond("GET", "/api/recoverability/groups/{classifier}", _ => Results.Json(new[] { new { id = "g", title = "T", count = 3, last = "2024-02-01T00:00:00Z", operation_status = "None" } })); + + var groups = JsonNode.Parse(await client.GetStringAsync("api/recoverability/groups/Exception%20Type%20and%20Stack%20Trace"))!.AsArray(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(groups, Has.Count.EqualTo(1)); + Assert.That(groups[0]!["count"]!.GetValue(), Is.EqualTo(5)); + Assert.That(memberA.Requests, Has.Some.Contains("Exception%20Type%20and%20Stack%20Trace"), "the path is forwarded as the client encoded it"); + } + } + + [Test] + public async Task An_unreachable_member_is_reported_as_incomplete() + { + memberA.Respond("GET", "/api/customchecks", ctx => List(ctx, [])); + await memberB.DisposeAsync(); + + var response = await client.GetAsync("api/customchecks?status=fail"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(response.Headers.GetValues("X-Particular-Incomplete-Results").Single(), Does.EndWith(":unavailable")); + } + } + + static IResult List(HttpContext context, IEnumerable rows) + { + var all = rows.ToList(); + var page = int.TryParse(context.Request.Query["page"], out var p) ? p : 1; + var perPage = int.TryParse(context.Request.Query["per_page"], out var pp) ? pp : 50; + context.Response.Headers["Total-Count"] = all.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); + return Results.Content(new JsonArray([.. all.Skip((page - 1) * perPage).Take(perPage)]).ToJsonString(), "application/json"); + } + + static IEnumerable Failures(string member, params int[] minutes) => + minutes.Select(minute => (JsonNode)new JsonObject + { + ["id"] = $"{member}-{minute}", + ["message_id"] = $"{member}-{minute}", + ["time_of_failure"] = DateTime.UnixEpoch.AddMinutes(minute).ToString("O") + }); + + static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json"); + + static async Task<(WebApplication, HttpClient)> StartProxy(params FakeMember[] members) + { + // The tests address the federated surfaces at /api and /monitoring for readability; a hosting + // primary uses FederationSettings.DefaultPrimaryPathBase and DefaultMonitoringPathBase. + var settings = new FederationSettings + { + DisplayName = "Federation under test", + MemberTimeout = TimeSpan.FromSeconds(5), + HealthProbeInterval = TimeSpan.FromHours(1), + PrimaryPathBase = "/api", + MonitoringPathBase = "/monitoring", + Members = [.. members.Select(m => new MemberSettings { Key = m.Key, PrimaryUrl = $"{m.BaseUrl}api/", MonitoringUrl = m.BaseUrl })] + }; + + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddServiceControlFederation(settings); + + var app = builder.Build(); + app.UseRouting(); + app.MapServiceControlFederation(); + await app.StartAsync(); + + var client = new HttpClient { BaseAddress = new Uri(Address(app)) }; + return (app, client); + } + + static string Address(WebApplication app) => + app.Services.GetRequiredService().Features.Get()!.Addresses.First().TrimEnd('/') + "/"; + + FakeMember memberA = null!; + FakeMember memberB = null!; + WebApplication proxy = null!; + HttpClient client = null!; + + sealed class FakeMember : IAsyncDisposable + { + FakeMember(string key) => Key = key; + + public string Key { get; } + + public string BaseUrl { get; private set; } = ""; + + public string Version { get; set; } = "6.12.0"; + + public ConcurrentQueue Requests { get; } = new(); + + public List Bodies(string methodAndPath) => + [.. bodies.Where(b => b.Key == methodAndPath).Select(b => b.Value)]; + + public void Respond(string method, string path, Func handler) => + handlers.Enqueue((method, new TemplateMatcher(TemplateParser.Parse(path.TrimStart('/')), []), handler)); + + public static async Task Start(string key) + { + var member = new FakeMember(key); + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + var app = builder.Build(); + app.Run(async context => + { + member.Requests.Enqueue($"{context.Request.Method} {context.Request.Path}{context.Request.QueryString}"); + if (context.Request.ContentLength > 0) + { + using var reader = new System.IO.StreamReader(context.Request.Body); + member.bodies.Add(new KeyValuePair($"{context.Request.Method} {context.Request.Path}", await reader.ReadToEndAsync())); + } + + context.Response.Headers["X-Particular-Version"] = member.Version; + foreach (var (method, matcher, handler) in member.handlers) + { + if (method == context.Request.Method && matcher.TryMatch(context.Request.Path, context.Request.RouteValues)) + { + await handler(context).ExecuteAsync(context); + return; + } + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + }); + member.app = app; + await app.StartAsync(); + member.BaseUrl = Address(app); + return member; + } + + public async ValueTask DisposeAsync() + { + if (app != null) + { + var stopping = app; + app = null; + await stopping.DisposeAsync(); + } + } + + readonly ConcurrentBag> bodies = []; + readonly ConcurrentQueue<(string Method, TemplateMatcher Matcher, Func Handler)> handlers = new(); + WebApplication? app; + } +} diff --git a/src/ServiceControl.Federation.UnitTests/FederationSettingsTests.cs b/src/ServiceControl.Federation.UnitTests/FederationSettingsTests.cs new file mode 100644 index 0000000000..f7710abd77 --- /dev/null +++ b/src/ServiceControl.Federation.UnitTests/FederationSettingsTests.cs @@ -0,0 +1,58 @@ +namespace ServiceControl.Federation.UnitTests; + +using System; +using System.Linq; +using Configuration; +using NUnit.Framework; + +[TestFixture] +class FederationSettingsTests +{ + [Test] + public void Instances_are_parsed_in_the_remote_instances_style() + { + var members = FederationSettings.ParseMembers(""" + [ + { "api_uri": "http://sc-eu:33333/api/", "monitoring_uri": "http://mon-eu:33633/", "key": "eu", "name": "Europe" }, + { "api_uri": "http://sc-us:33333/api/" } + ] + """); + + using (Assert.EnterMultipleScope()) + { + Assert.That(members.Select(m => m.Key), Is.EqualTo(new[] { "eu", "sc-us-33333" }), "a missing key is derived from the api_uri host and port"); + Assert.That(members[0].Name, Is.EqualTo("Europe")); + Assert.That(members[0].MonitoringUrl, Is.EqualTo("http://mon-eu:33633/")); + Assert.That(members[1].MonitoringUrl, Is.Null); + } + } + + [TestCase(null)] + [TestCase("")] + [TestCase("[]")] + public void No_instances_fail_validation(string? json) + { + var settings = new FederationSettings { Members = [.. FederationSettings.ParseMembers(json)] }; + + Assert.That(settings.Validate, Throws.InvalidOperationException.With.Message.Contains("FederatedInstances")); + } + + [Test] + public void Instances_that_derive_the_same_key_must_be_given_distinct_keys() + { + var settings = new FederationSettings + { + Members = [.. FederationSettings.ParseMembers("""[{ "api_uri": "http://localhost:33333/api/" }, { "api_uri": "http://localhost:33333/api" }]""")] + }; + + Assert.That(settings.Validate, Throws.InvalidOperationException.With.Message.Contains("distinct")); + } + + [Test] + public void Invalid_urls_fail_validation() + { + var settings = new FederationSettings { Members = [new MemberSettings { Key = "a", PrimaryUrl = "not a url" }] }; + + Assert.That(settings.Validate, Throws.InstanceOf().With.Message.Contains("api_uri")); + } +} diff --git a/src/ServiceControl.Federation.UnitTests/MergerTests.cs b/src/ServiceControl.Federation.UnitTests/MergerTests.cs new file mode 100644 index 0000000000..a2dbb4c85e --- /dev/null +++ b/src/ServiceControl.Federation.UnitTests/MergerTests.cs @@ -0,0 +1,163 @@ +namespace ServiceControl.Federation.UnitTests; + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Aggregation; +using NUnit.Framework; +using Routing; + +[TestFixture] +class FailureGroupMergerTests +{ + [Test] + public void Groups_with_the_same_id_are_combined() + { + var merged = FailureGroupMerger.MergeLists( + [ + [Group("g1", 3, "2024-01-01T00:00:00Z", "2024-01-05T00:00:00Z")], + [Group("g1", 4, "2023-12-01T00:00:00Z", "2024-01-02T00:00:00Z"), Group("g2", 1, "2024-02-01T00:00:00Z", "2024-02-01T00:00:00Z")] + ]); + + Assert.That(merged, Has.Count.EqualTo(2)); + var g1 = merged.Single(g => JsonValues.Text(g, "id") == "g1"); + using (Assert.EnterMultipleScope()) + { + Assert.That(JsonValues.Number(g1, "count"), Is.EqualTo(7)); + Assert.That(JsonValues.Text(g1, "first"), Is.EqualTo("2023-12-01T00:00:00Z")); + Assert.That(JsonValues.Text(g1, "last"), Is.EqualTo("2024-01-05T00:00:00Z")); + Assert.That(JsonValues.Text(merged[0], "id"), Is.EqualTo("g2"), "ordered by last, newest first"); + } + } + + [Test] + public void Operation_state_reflects_the_most_active_member() + { + var done = Group("g1", 0, null, null); + done["operation_status"] = "Completed"; + done["operation_progress"] = 1.0; + done["operation_remaining_count"] = 0; + done["operation_messages_completed_count"] = 30; + done["operation_completion_time"] = "2024-01-01T00:00:00Z"; + + var running = Group("g1", 10, null, null); + running["operation_status"] = "Forwarding"; + running["operation_progress"] = 0.0; + running["operation_remaining_count"] = 10; + running["operation_messages_completed_count"] = 0; + + var idle = Group("g1", 5, null, null); + idle["operation_status"] = "None"; + + var merged = FailureGroupMerger.Merge([done, running, idle]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(JsonValues.Text(merged, "operation_status"), Is.EqualTo("Forwarding")); + Assert.That(JsonValues.Number(merged, "operation_progress"), Is.EqualTo(0.75)); + Assert.That(JsonValues.Number(merged, "operation_remaining_count"), Is.EqualTo(10)); + Assert.That(merged["operation_completion_time"], Is.Null, "not complete until every member is"); + } + } + + static JsonObject Group(string id, int count, string? first, string? last) => new() + { + ["id"] = id, + ["title"] = "System.Exception", + ["type"] = "Exception Type and Stack Trace", + ["count"] = count, + ["first"] = first, + ["last"] = last, + ["operation_status"] = "None" + }; +} + +[TestFixture] +class MonitoredEndpointMergerTests +{ + [Test] + public void Endpoints_with_the_same_name_are_combined() + { + var merged = MonitoredEndpointMerger.MergeList( + [ + [Endpoint("Sales", stale: false, throughput: [10, 10], processingTime: [100, 100])], + [Endpoint("Sales", stale: true, throughput: [30, 0], processingTime: [200, 0]), Endpoint("Billing", stale: true, throughput: [1, 1], processingTime: [1, 1])] + ]); + + Assert.That(merged, Has.Count.EqualTo(2)); + var sales = merged[0]!; + using (Assert.EnterMultipleScope()) + { + Assert.That(JsonValues.Bool(sales, "isStale"), Is.False); + Assert.That(Points(sales, "throughput"), Is.EqualTo(new[] { 40.0, 10.0 })); + Assert.That(Points(sales, "processingTime"), Is.EqualTo(new[] { 175.0, 100.0 }), "weighted by throughput"); + Assert.That((JsonValues.At(sales, "endpointInstanceIds") as JsonArray)!, Has.Count.EqualTo(2)); + } + } + + static IEnumerable Points(JsonNode endpoint, string metric) => + (JsonValues.At(endpoint, $"metrics.{metric}.points") as JsonArray)!.Select(JsonValues.AsNumber); + + static JsonObject Endpoint(string name, bool stale, double[] throughput, double[] processingTime) => new() + { + ["name"] = name, + ["isStale"] = stale, + ["endpointInstanceIds"] = new JsonArray($"{name}-{stale}"), + ["metrics"] = new JsonObject + { + ["throughput"] = new JsonObject { ["average"] = throughput.Average(), ["points"] = new JsonArray([.. throughput.Select(p => (JsonNode)p)]) }, + ["processingTime"] = new JsonObject { ["average"] = processingTime.Average(), ["points"] = new JsonArray([.. processingTime.Select(p => (JsonNode)p)]) } + } + }; +} + +[TestFixture] +class InstanceIdCodecTests +{ + [TestCase("/messages/1/body?instance_id=aHR0cDovL2E.", "a", "/messages/1/body?instance_id=a~aHR0cDovL2E.")] + [TestCase("/messages/1/body", "a", "/messages/1/body?instance_id=a~")] + [TestCase("/messages/1/body?x=1", "b", "/messages/1/body?x=1&instance_id=b~")] + public void Body_urls_carry_the_member_key(string url, string member, string expected) => + Assert.That(InstanceIdCodec.RewriteBodyUrl(url, member), Is.EqualTo(expected)); + + [Test] + public void Encoded_ids_round_trip() + { + Assert.That(InstanceIdCodec.TryDecode(InstanceIdCodec.Encode("eu", "aHR0cDovL2E."), out var key, out var id), Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(key, Is.EqualTo("eu")); + Assert.That(id, Is.EqualTo("aHR0cDovL2E.")); + } + + Assert.That(InstanceIdCodec.TryDecode("aHR0cDovL2E.", out _, out _), Is.False, "a member's own id is not routable"); + } + + [Test] + public void Trees_are_rewritten_in_place() + { + var row = new JsonObject + { + ["instance_id"] = "abc", + ["body_url"] = "/messages/1/body?instance_id=abc", + ["nested"] = new JsonArray(new JsonObject { ["body_url"] = "/messages/2/body" }) + }; + + InstanceIdCodec.RewriteTree(row, "eu"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(JsonValues.Text(row, "instance_id"), Is.EqualTo("eu~abc")); + Assert.That(JsonValues.Text(row, "body_url"), Is.EqualTo("/messages/1/body?instance_id=eu~abc")); + Assert.That(JsonValues.Text(row["nested"]![0], "body_url"), Is.EqualTo("/messages/2/body?instance_id=eu~")); + } + } +} + +[TestFixture] +class VersionsTests +{ + [Test] + public void The_lowest_version_wins() => + Assert.That(Versions.Lowest(["6.10.0", "6.9.1", null, "6.9.1-alpha.3+sha"]), Is.EqualTo("6.9.1")); +} diff --git a/src/ServiceControl.Federation.UnitTests/NUnitSettings.cs b/src/ServiceControl.Federation.UnitTests/NUnitSettings.cs new file mode 100644 index 0000000000..8b76b23ac7 --- /dev/null +++ b/src/ServiceControl.Federation.UnitTests/NUnitSettings.cs @@ -0,0 +1,5 @@ +using NUnit.Framework; + +[assembly: FixtureLifeCycle(LifeCycle.InstancePerTestCase)] +[assembly: Parallelizable(ParallelScope.All)] +[assembly: LevelOfParallelism(4)] diff --git a/src/ServiceControl.Federation.UnitTests/RouteCoverageTests.cs b/src/ServiceControl.Federation.UnitTests/RouteCoverageTests.cs new file mode 100644 index 0000000000..4b4acd0249 --- /dev/null +++ b/src/ServiceControl.Federation.UnitTests/RouteCoverageTests.cs @@ -0,0 +1,91 @@ +namespace ServiceControl.Federation.UnitTests; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Api; +using Members; +using NUnit.Framework; + +/// +/// Every route a ServiceControl instance exposes must have a federation strategy. The approved route +/// lists are the instances' own approval files, so adding a route to ServiceControl fails this test +/// until the route is classified in or . +/// +[TestFixture] +class RouteCoverageTests +{ + [Test] + public void Every_primary_route_has_a_strategy() + { + var approved = ApprovedRoutes("Primary.txt", PrimaryPrefix) + .Concat(ApprovedRoutes("Licensing.txt", _ => "licensing/")); + + AssertCovered(approved, PrimaryRoutes.All); + } + + [Test] + public void Every_monitoring_route_has_a_strategy() => + AssertCovered(ApprovedRoutes("Monitoring.txt", MonitoringPrefix), MonitoringRoutes.All); + + [Test] + public void Route_tables_have_no_duplicates() + { + var duplicates = FederationHostingExtensions.AllRoutes + .SelectMany(route => route.Methods.Select(method => $"{route.Surface} {method} {Normalise(route.Template)}")) + .GroupBy(key => key) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToList(); + + Assert.That(duplicates, Is.Empty); + } + + static void AssertCovered(IEnumerable approved, IEnumerable routes) + { + var proxied = routes + .SelectMany(route => route.Methods.Select(method => $"{method} {Normalise(route.Template)}")) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var missing = approved.Where(route => !proxied.Contains(route)).ToList(); + + Assert.That(missing, Is.Empty, "These routes have no federation strategy. Add them to the route table."); + } + + // The approval files print only the method-level template; these controllers add a prefix. + static string PrimaryPrefix(string controller) => controller switch + { + _ when controller.EndsWith("AuthenticationController", StringComparison.Ordinal) => "authentication/", + _ when controller.EndsWith("SystemMaintenanceController", StringComparison.Ordinal) => "maintenance/", + _ => "" + }; + + static string MonitoringPrefix(string controller) => + controller.EndsWith("MyRoutesController", StringComparison.Ordinal) ? "api/" : ""; + + static IEnumerable ApprovedRoutes(string file, Func prefixFor) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "ApprovedRoutes", file); + foreach (var line in File.ReadAllLines(path).Where(l => l.Contains("=>", StringComparison.Ordinal))) + { + var match = RouteLine().Match(line); + Assert.That(match.Success, Is.True, $"Unexpected approval line: {line}"); + + var controller = match.Groups["controller"].Value; + var template = prefixFor(controller) + match.Groups["template"].Value.TrimStart('/'); + foreach (var method in match.Groups["methods"].Value.Split('/')) + { + yield return $"{method} {Normalise(template)}"; + } + } + } + + static string Normalise(string template) => + string.Join('/', template.Trim('/').Split('/').Select(segment => Parameter().Replace(segment, "{}"))).ToLowerInvariant(); + + static Regex RouteLine() => new(@"^(?[A-Z/]+) (?