mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user