mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
371 lines
17 KiB
C#
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);
|
|
}
|
|
}
|