Files
Radicade/ServerTests/HubRealtimeTests.cs
T
2026-10-16 23:33:13 -07:00

371 lines
17 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[Collection(DbFactoryCollection.Name)]
public class HubRealtimeTests
{
private sealed class TestDbContextFactory(bool failToCreate = false) : IDbContextFactory<RecDBContext>
{
private readonly DbContextOptions<RecDBContext> 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<NotifyHub>
{
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<IActionResult> GetStatusAsync(
string service,
IDbContextFactory<RecDBContext>? 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<ObjectResult>(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<AllowAnonymousAttribute>());
var routeTemplates = method.GetCustomAttributes<HttpGetAttribute>()
.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<HttpGetAttribute>(), attribute => string.IsNullOrEmpty(attribute.Template));
Assert.Contains("data/", typeof(DataCDN).GetCustomAttributes<RouteAttribute>().Select(attribute => attribute.Template));
continue;
}
Assert.Contains("~/" + service, routeTemplates);
var result = Assert.IsType<OkObjectResult>(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<HttpGetAttribute>()
.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<ObjectResult>(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<ObjectResult>(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<ObjectResult>(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<OkObjectResult>(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<ObjectResult>(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);
}
}