using System.Reflection; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using RadicadeServer.API; using RadicadeServer.API.Notifications; using RadicadeServer.CDN; using RadicadeServer.DB; using RadicadeServer.RecNet; using Xunit; namespace RadicadeServer.Tests; /// /// Realtime is one socket, and it fails silently in every direction: a URL that lands /// somewhere unmapped, or a socket the server cannot tie to an account, leaves the /// connection looking healthy while every notification for that player is dropped - no ban /// kick, no invite, no presence. These cover the two halves that made that possible: the /// set of addresses the hub answers on, and putting the player's token on a socket the /// client builds without one. /// [Collection(DbFactoryCollection.Name)] public class HubRealtimeTests { private sealed class TestDbContextFactory(bool failToCreate = false) : IDbContextFactory { private readonly DbContextOptions options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; public RecDBContext CreateDbContext() { if (failToCreate) throw new InvalidOperationException("Database unavailable"); return new RecDBContext(options); } } private sealed class TestHubContext : IHubContext { public IHubClients Clients => null!; public IGroupManager Groups => null!; } private static readonly string[] HealthServiceNames = [ "auth", "ai", "data", "www", "cards", "notify", "images", "cdn", "commerce", "datacollection", "discovery", "matchmaking", "storage", "chat", "geo", "leaderboard", "accounts", "link", "locker", "moderation", "roomcomments", "roomieintegrations", "clubs", "rooms", "econ", "bugreporting", "cms", "gamelogs", "platformnotifications", "stringscdn", "strings", "lists", "studio", "studiocdn", "thorn", "videos" ]; private static async Task GetStatusAsync( string service, IDbContextFactory? dbFactory, bool hubAvailable = true) { var previousFactory = Notify.DbFactory; var previousContext = Notify.Context; try { Notify.DbFactory = dbFactory!; Notify.Context = hubAvailable ? new TestHubContext() : null!; var api = new ServiceRootHealthAPI(dbFactory!); var httpContext = new DefaultHttpContext(); httpContext.Request.Path = "/" + service; api.ControllerContext = new ControllerContext { HttpContext = httpContext }; return await api.GetServiceStatus(); } finally { Notify.DbFactory = previousFactory; Notify.Context = previousContext; } } private static JsonElement ResponseBody(IActionResult result) { var value = Assert.IsAssignableFrom(result).Value; using var document = JsonDocument.Parse(JsonSerializer.Serialize(value)); return document.RootElement.Clone(); } [Fact] public async Task EveryNameserverServiceRootHasAnonymousHealthyStatusJson() { var method = typeof(ServiceRootHealthAPI).GetMethod(nameof(ServiceRootHealthAPI.GetServiceStatus))!; Assert.NotEmpty(method.GetCustomAttributes()); var routeTemplates = method.GetCustomAttributes() .Select(attribute => attribute.Template) .ToArray(); var advertised = typeof(NameServerResponse).GetProperties() .ToDictionary(property => property.Name, property => new Uri((string)property.GetValue(DBConfig.NameServer)!)); // Every advertised endpoint is a host of its own under the service domain and carries // no path at all: the client appends its own path literal to the base it reads here, // and the host label is what NameserverRedirect turns back into a route prefix. The // label is the route unless the server says otherwise, which is why the comparison // below goes through ServiceRoute: notifications is served at /notify. Assert.All(advertised.Values, url => { Assert.Equal(DBConfig.ServiceDomain, url.Host.Split('.', 2).ElementAtOrDefault(1)); Assert.Equal("/", url.AbsolutePath); }); var advertisedServices = advertised .Where(pair => pair.Key is not nameof(NameServerResponse.API) and not nameof(NameServerResponse.PlayerSettings)) .Select(pair => DBConfig.ServiceRoute(pair.Value.Host.Split('.')[0])) .OrderBy(service => service, StringComparer.Ordinal) .ToArray(); var expectedRoutes = HealthServiceNames .Where(service => service != "data") .Select(service => "~/" + service) .OrderBy(route => route, StringComparer.Ordinal) .ToArray(); Assert.Equal(HealthServiceNames.OrderBy(service => service, StringComparer.Ordinal), advertisedServices); Assert.Equal(expectedRoutes, routeTemplates.OrderBy(route => route, StringComparer.Ordinal)); Assert.Contains("~/platformnotifications", routeTemplates); Assert.DoesNotContain(routeTemplates, route => route!.Contains('*')); Assert.DoesNotContain(routeTemplates, route => route is "~/" or "~/ns"); var factory = new TestDbContextFactory(); foreach (var service in HealthServiceNames) { if (service == "data") { var dataRoot = typeof(DataCDN).GetMethod(nameof(DataCDN.Index))!; Assert.Contains(dataRoot.GetCustomAttributes(), attribute => string.IsNullOrEmpty(attribute.Template)); Assert.Contains("data/", typeof(DataCDN).GetCustomAttributes().Select(attribute => attribute.Template)); continue; } Assert.Contains("~/" + service, routeTemplates); var result = Assert.IsType(await GetStatusAsync(service, factory)); var body = ResponseBody(result); Assert.Equal(service, body.GetProperty("service").GetString()); Assert.Equal("ok", body.GetProperty("status").GetString()); } } // The nameserver names each service on its own host, and the patch has to turn that host // back into the route this server serves it under: the client appends its own path literal // to the advertised base, so a host that lands anywhere else is a service that answers // nothing - with no error anywhere to say so. This reads the advertised names, the // redirect and the server's own routes together, so the three cannot drift apart: a name // misspelled in either list fails here rather than in a client that half-works. [Fact] public void EveryAdvertisedServiceHostLandsOnAServiceRootTheServerServes() { var roots = typeof(ServiceRootHealthAPI) .GetMethod(nameof(ServiceRootHealthAPI.GetServiceStatus))! .GetCustomAttributes() .Select(attribute => attribute.Template!.TrimStart('~', '/').ToLowerInvariant()) .ToHashSet(StringComparer.Ordinal); // data answers from its own controller at /data rather than from the health roots. roots.Add("data"); foreach (var property in typeof(NameServerResponse).GetProperties()) { var advertised = (string)property.GetValue(DBConfig.NameServer)!; var label = new Uri(advertised).Host.Split('.')[0]; var rewritten = new Uri(NameserverRedirect.Rewrite(advertised + "/probe", DBConfig.ServerUrl)); var segment = rewritten.AbsolutePath.TrimStart('/').Split('/')[0].ToLowerInvariant(); Assert.Equal(new Uri(DBConfig.ServerUrl).Host, rewritten.Host); // api is the bare base every unprefixed call already went to, so it must not gain // a segment at all. if (property.Name == nameof(NameServerResponse.API)) { Assert.Equal("probe", rewritten.AbsolutePath.TrimStart('/')); continue; } Assert.Equal(DBConfig.ServiceRoute(label), segment); // playersettings is advertised for completeness; the client's settings calls go to // /api/settings/v2, so there is no root of that name to land on. if (property.Name == nameof(NameServerResponse.PlayerSettings)) continue; Assert.Contains(segment, roots); } } [Fact] public async Task NotificationServiceRootReturnsAnErrorCodeWhenTheHubIsUnavailable() { var result = Assert.IsType(await GetStatusAsync("notify", new TestDbContextFactory(), hubAvailable: false)); Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode); var body = ResponseBody(result); Assert.Equal("notify", body.GetProperty("service").GetString()); Assert.Equal("NOTIFY_HUB_UNAVAILABLE", body.GetProperty("status").GetString()); } [Fact] public async Task ServiceRootsReturnTheirErrorCodeWhenTheDatabaseIsUnavailable() { var result = Assert.IsType(await GetStatusAsync("moderation", new TestDbContextFactory(failToCreate: true))); Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode); var body = ResponseBody(result); Assert.Equal("moderation", body.GetProperty("service").GetString()); Assert.Equal("MODERATION_DATABASE_UNAVAILABLE", body.GetProperty("status").GetString()); } [Fact] public async Task NotificationServiceRootReturnsAnErrorCodeWhenItsDatabaseIsNotConfigured() { var result = Assert.IsType(await GetStatusAsync("notify", null)); Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode); Assert.Equal("NOTIFY_DATABASE_NOT_CONFIGURED", ResponseBody(result).GetProperty("status").GetString()); } [Fact] public void DataServiceRootReportsHealthyAndUnavailableStorage() { var previousStorage = DBConfig.CDNStorage; var root = Directory.CreateTempSubdirectory("radicade-data-health-test-"); try { DBConfig.CDNStorage = root.FullName; var healthy = Assert.IsType(new DataCDN().Index()); var healthyBody = ResponseBody(healthy); Assert.Equal("data", healthyBody.GetProperty("service").GetString()); Assert.Equal("ok", healthyBody.GetProperty("status").GetString()); DBConfig.CDNStorage = Path.Combine(root.FullName, "missing"); var unavailable = Assert.IsType(new DataCDN().Index()); Assert.Equal(StatusCodes.Status503ServiceUnavailable, unavailable.StatusCode); var errorBody = ResponseBody(unavailable); Assert.Equal("data", errorBody.GetProperty("service").GetString()); Assert.Equal("DATA_STORAGE_UNAVAILABLE", errorBody.GetProperty("status").GetString()); } finally { DBConfig.CDNStorage = previousStorage; root.Delete(recursive: true); } } // The client builds the hub URL as the nameserver response's "Notifications" base plus // the client's own "hub/v1" literal, and the two ways of joining them disagree about the // slash. Every shape that join can produce has to answer, or a URL-shape mismatch kills // realtime while every REST call keeps working. [Theory] [InlineData("/notify/hub/v1")] [InlineData("/notify/hub")] [InlineData("/notifyhub/v1")] [InlineData("/hub/v1")] [InlineData("/hub")] public void EveryHubAddressShapeTheClientCanBuildAnswers(string path) { Assert.Contains(path, NotifyHubRoutes.Paths); Assert.True(NotifyHubRoutes.IsHubPath(path)); // SignalR negotiates over HTTP first, then upgrades. Assert.True(NotifyHubRoutes.IsHubPath(path + "/negotiate")); // Path matching never depends on the casing a proxy or a client happened to use. Assert.True(NotifyHubRoutes.IsHubPath(path.ToUpperInvariant())); } [Theory] [InlineData("/notify")] [InlineData("/notify/")] [InlineData("/rooms/rooms/1")] [InlineData("/matchmaking/player")] [InlineData("/hubris")] public void UnrelatedAddressesAreNotHubAddresses(string path) => Assert.False(NotifyHubRoutes.IsHubPath(path)); // The socket the client opens, exactly as its own log shows it. It carries the // connection id and nothing else: no access_token, and no Authorization header, because // SignalR's WebSocket transport does not copy the one the game uses for REST calls. That // is what left the server with an anonymous connection whose every notification was // dropped. [Theory] [InlineData("wss://ns.radicade.app/notify/hub/v1?&id=abc")] [InlineData("wss://ns.radicade.app/notify/hub/v1?id=abc")] [InlineData("wss://ns.radicade.app/hub/v1?id=abc")] [InlineData("wss://ns.radicade.app/notifyhub/v1?id=abc")] [InlineData("wss://ns.radicade.app/hub?id=abc")] [InlineData("https://ns.radicade.app/notify/hub/v1/negotiate")] public void HubAddressesAreRecognised(string url) => Assert.True(NameserverRedirect.IsHubUrl(url)); [Theory] [InlineData("https://ns.radicade.app/rooms/rooms/1")] [InlineData("https://ns.radicade.app/hubris")] [InlineData("https://ns.radicade.app/notify/")] [InlineData("https://ns.radicade.app/api/settings/v2/")] [InlineData("https://ns.radicade.app/accounts/account/me")] public void OtherAddressesAreNotHubAddresses(string url) => Assert.False(NameserverRedirect.IsHubUrl(url)); // The address the client actually opens, rebuilt the canonical way round: the player's // token first, the connection id kept. [Fact] public void TheClientsOwnHubAddressGetsThePlayersToken() { var opened = "wss://ns.radicade.app/notify/hub/v1?&id=z9bNeechDZOGXdxy5lQp0Q"; var authenticated = NameserverRedirect.EnsureHubToken(opened, "eyJhbGciOi.probe.token"); Assert.Equal("wss://ns.radicade.app/notify/hub/v1?access_token=eyJhbGciOi.probe.token&id=z9bNeechDZOGXdxy5lQp0Q", authenticated); Assert.True(NameserverRedirect.HasHubToken(authenticated)); // Idempotent: a second pass must not stack a second copy on the address. Assert.Equal(authenticated, NameserverRedirect.EnsureHubToken(authenticated, "eyJhbGciOi.probe.token")); } [Theory] [InlineData("/notify/hub/v1?id=abc")] [InlineData("/notify/hub/v1")] [InlineData("/hub")] public void EveryHubShapeCanCarryTheToken(string path) { var authenticated = NameserverRedirect.EnsureHubToken("wss://host" + path, "token-value"); Assert.Contains("access_token=token-value", authenticated); Assert.True(NameserverRedirect.HasHubToken(authenticated)); } [Fact] public void ASocketThatAlreadyCarriesATokenIsLeftAlone() { var already = "wss://host/hub/v1?access_token=theirs&id=abc"; Assert.Equal(already, NameserverRedirect.EnsureHubToken(already, "ours")); Assert.True(NameserverRedirect.HasHubToken(already)); } [Fact] public void TheBearerPrefixIsNotPartOfTheToken() { Assert.Equal("wss://host/hub/v1?access_token=raw", NameserverRedirect.EnsureHubToken("wss://host/hub/v1", "Bearer raw")); } // Nothing may be added to an ordinary API call: a token in the wrong URL is a token // leaked into logs and into whatever the address is handed to. [Fact] public void OrdinaryCallsAreNeverTouched() { const string api = "https://host/rooms/rooms/1?include=all"; Assert.Equal(api, NameserverRedirect.EnsureHubToken(api, "token-value")); Assert.Equal("wss://host/hub/v1", NameserverRedirect.EnsureHubToken("wss://host/hub/v1", null)); Assert.Equal("wss://host/hub/v1", NameserverRedirect.EnsureHubToken("wss://host/hub/v1", "")); Assert.Equal("wss://host/hub/v1", NameserverRedirect.EnsureHubToken("wss://host/hub/v1", "Bearer ")); } // The stock client's hub host is the real RecNet's; redirecting it and authenticating it // are two steps of the same journey and must compose. [Fact] public void AStockClientsHubSocketIsRedirectedAndThenAuthenticated() { var redirected = NameserverRedirect.RewriteWebSocket("wss://notify.rec.net/hub/v1?id=abc", "https://ns.radicade.app"); Assert.Equal("wss://ns.radicade.app/notify/hub/v1?id=abc", redirected); var authenticated = NameserverRedirect.EnsureHubToken(redirected, "token-value"); Assert.Equal("wss://ns.radicade.app/notify/hub/v1?access_token=token-value&id=abc", authenticated); } }