mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Split remaining service routes
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.HostFiltering;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class DorkNetServerFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000";
|
||||
private readonly string _dataRoot;
|
||||
|
||||
public DorkNetServerFactory()
|
||||
{
|
||||
_dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dataRoot);
|
||||
}
|
||||
|
||||
public string ApexDomain => "dork.test";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.UseSetting("Domain:Apex", ApexDomain);
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AllowedHosts"] = "*",
|
||||
["Database:Provider"] = "sqlite",
|
||||
["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"),
|
||||
["Domain:Apex"] = ApexDomain,
|
||||
["Jwt:Secret"] = JwtSecret,
|
||||
["S3:Endpoint"] = "",
|
||||
["S3:AccessKey"] = "",
|
||||
["S3:SecretKey"] = "",
|
||||
["DorkNet:DefaultClientVersion"] = "december_2020_12_18",
|
||||
["DorkNet:SupportedVersions:0"] = "december_2020_12_18",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.PostConfigure<HostFilteringOptions>(options =>
|
||||
{
|
||||
options.AllowedHosts =
|
||||
[
|
||||
ApexDomain,
|
||||
$"*.{ApexDomain}",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
];
|
||||
options.AllowEmptyHosts = true;
|
||||
options.IncludeFailureMessage = true;
|
||||
});
|
||||
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)),
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "https://api.rec.net/",
|
||||
ValidateAudience = false,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dataRoot))
|
||||
{
|
||||
Directory.Delete(_dataRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Test cleanup must not hide the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public static partial class EndpointContractDiscovery
|
||||
{
|
||||
public static IReadOnlyList<EndpointContract> Discover()
|
||||
{
|
||||
var serverAssembly = typeof(Program).Assembly;
|
||||
var controllerBaseType = typeof(ControllerBase);
|
||||
return serverAssembly.GetTypes()
|
||||
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t))
|
||||
.SelectMany(DiscoverControllerRoutes)
|
||||
.OrderBy(route => route.Method, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.SamplePath, StringComparer.Ordinal)
|
||||
.ThenBy(route => route.Source, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<EndpointContract> DiscoverControllerRoutes(Type controller)
|
||||
{
|
||||
var controllerName = ControllerName(controller);
|
||||
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Select(a => a.Template)
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Cast<string>()
|
||||
.DefaultIfEmpty("")
|
||||
.ToArray();
|
||||
|
||||
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<HttpMethodAttribute>()
|
||||
.ToArray();
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
var attrMethods = attr.HttpMethods.Any()
|
||||
? attr.HttpMethods
|
||||
: new[] { "ANY" };
|
||||
|
||||
foreach (var httpMethod in attrMethods)
|
||||
{
|
||||
foreach (var controllerTemplate in controllerRouteTemplates)
|
||||
{
|
||||
var template = CombineTemplates(controllerTemplate, attr.Template);
|
||||
template = ReplaceRouteTokens(template, controllerName, method.Name);
|
||||
yield return new EndpointContract(
|
||||
httpMethod.ToUpperInvariant(),
|
||||
template,
|
||||
ToSamplePath(template),
|
||||
$"{controller.FullName}.{method.Name}",
|
||||
RequiresAuthorization(controller, method),
|
||||
IsAdminEndpoint(controller, method),
|
||||
ConsumesContentTypes(controller, method));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiresAuthorization(Type controller, MethodInfo method)
|
||||
{
|
||||
var attributes = controller.GetCustomAttributes(inherit: true)
|
||||
.Concat(method.GetCustomAttributes(inherit: true))
|
||||
.ToArray();
|
||||
|
||||
if (attributes.OfType<IAllowAnonymous>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return attributes.OfType<IAuthorizeData>().Any();
|
||||
}
|
||||
|
||||
private static bool IsAdminEndpoint(Type controller, MethodInfo method)
|
||||
{
|
||||
return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true
|
||||
|| method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string[] ConsumesContentTypes(Type controller, MethodInfo method)
|
||||
{
|
||||
var consumes = method.GetCustomAttributes(inherit: true)
|
||||
.Concat(controller.GetCustomAttributes(inherit: true))
|
||||
.OfType<ConsumesAttribute>()
|
||||
.SelectMany(a => a.ContentTypes)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return consumes.Length == 0
|
||||
? ["application/json"]
|
||||
: consumes;
|
||||
}
|
||||
|
||||
private static string ControllerName(Type controller)
|
||||
{
|
||||
const string suffix = "Controller";
|
||||
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
|
||||
? controller.Name[..^suffix.Length]
|
||||
: controller.Name;
|
||||
}
|
||||
|
||||
private static string CombineTemplates(string? controllerTemplate, string? actionTemplate)
|
||||
{
|
||||
controllerTemplate ??= "";
|
||||
actionTemplate ??= "";
|
||||
|
||||
if (actionTemplate.StartsWith("~/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate[2..].TrimStart('/');
|
||||
}
|
||||
|
||||
if (actionTemplate.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return "/" + actionTemplate.TrimStart('/');
|
||||
}
|
||||
|
||||
var left = controllerTemplate.Trim('/');
|
||||
var right = actionTemplate.Trim('/');
|
||||
|
||||
if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(left))
|
||||
{
|
||||
return "/" + right;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(right))
|
||||
{
|
||||
return "/" + left;
|
||||
}
|
||||
|
||||
return "/" + left + "/" + right;
|
||||
}
|
||||
|
||||
private static string ReplaceRouteTokens(string template, string controllerName, string actionName)
|
||||
{
|
||||
return template
|
||||
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ToSamplePath(string template)
|
||||
{
|
||||
var path = RouteParameterRegex().Replace(template, match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value.ToLowerInvariant();
|
||||
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
|
||||
|
||||
if (name.Contains("playerid") || name.Contains("accountid") || name == "id")
|
||||
{
|
||||
return EndpointContract.TestPlayerToken;
|
||||
}
|
||||
|
||||
if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id"))
|
||||
{
|
||||
return "1";
|
||||
}
|
||||
|
||||
if (constraint.Contains("bool"))
|
||||
{
|
||||
return "true";
|
||||
}
|
||||
|
||||
if (constraint.Contains("guid"))
|
||||
{
|
||||
return "00000000-0000-0000-0000-000000000001";
|
||||
}
|
||||
|
||||
if (name.Contains("roomname"))
|
||||
{
|
||||
return "DormRoom";
|
||||
}
|
||||
|
||||
if (name.Contains("locale"))
|
||||
{
|
||||
return "en";
|
||||
}
|
||||
|
||||
return "sample";
|
||||
});
|
||||
|
||||
path = Regex.Replace(path, "/+", "/");
|
||||
return path.StartsWith('/') ? path : "/" + path;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
|
||||
private static partial Regex RouteParameterRegex();
|
||||
}
|
||||
|
||||
public sealed record EndpointContract(
|
||||
string Method,
|
||||
string Template,
|
||||
string SamplePath,
|
||||
string Source,
|
||||
bool RequiresAuthorization,
|
||||
bool IsAdminEndpoint,
|
||||
string[] Consumes)
|
||||
{
|
||||
public const string TestPlayerToken = "__test_player_id__";
|
||||
|
||||
public string PathFor(GameClientSession session)
|
||||
{
|
||||
return SamplePath.Replace(
|
||||
TestPlayerToken,
|
||||
session.PlayerId.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed class GameClientEndpointContractTests :
|
||||
IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public GameClientEndpointContractTests(DorkNetServerFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Game_client_contract_probe_reaches_every_controller_route()
|
||||
{
|
||||
var routes = EndpointContractDiscovery.Discover();
|
||||
Assert.NotEmpty(routes);
|
||||
|
||||
using var client = _factory.CreateClient(new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
});
|
||||
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var route in routes)
|
||||
{
|
||||
var result = await ProbeAsync(client, route, session, _factory.ApexDomain);
|
||||
if (!result.Passed)
|
||||
{
|
||||
failures.Add(
|
||||
$"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}"
|
||||
+ (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}"));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
failures.Count == 0,
|
||||
$"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}"
|
||||
+ string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
private static async Task<EndpointProbeResult> ProbeAsync(
|
||||
HttpClient client,
|
||||
EndpointContract route,
|
||||
GameClientSession session,
|
||||
string apexDomain)
|
||||
{
|
||||
var path = route.PathFor(session);
|
||||
path = AddClientQuery(path, route, session);
|
||||
var host = HostFor(path, apexDomain);
|
||||
using var request = new HttpRequestMessage(
|
||||
ToHttpMethod(route.Method),
|
||||
new Uri($"http://{host}{path}"));
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
||||
|
||||
if (NeedsRequestBody(request.Method))
|
||||
{
|
||||
request.Content = CreateContent(route, session);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.SendAsync(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var passed = ResponseMatchesContract(route, response.StatusCode);
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
statusCode,
|
||||
null,
|
||||
Excerpt(responseText),
|
||||
passed);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new EndpointProbeResult(
|
||||
route.Method,
|
||||
host,
|
||||
path,
|
||||
ExpectedContract(route),
|
||||
null,
|
||||
ex.Message,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ResponseMatchesContract(
|
||||
EndpointContract route,
|
||||
HttpStatusCode statusCode)
|
||||
{
|
||||
if ((int)statusCode >= 500)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (statusCode is HttpStatusCode.MethodNotAllowed
|
||||
or HttpStatusCode.NotImplemented
|
||||
or HttpStatusCode.UpgradeRequired)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization
|
||||
&& statusCode is HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ExpectedContract(EndpointContract route)
|
||||
{
|
||||
if (route.IsAdminEndpoint)
|
||||
{
|
||||
return "admin endpoint may reject non-admin test player, but must not 5xx/405/426";
|
||||
}
|
||||
|
||||
if (route.RequiresAuthorization)
|
||||
{
|
||||
return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources";
|
||||
}
|
||||
|
||||
return "game-client request should not 5xx/405/426";
|
||||
}
|
||||
|
||||
private static HttpMethod ToHttpMethod(string method)
|
||||
{
|
||||
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
|
||||
? HttpMethod.Get
|
||||
: new HttpMethod(method);
|
||||
}
|
||||
|
||||
private static bool NeedsRequestBody(HttpMethod method)
|
||||
{
|
||||
return method == HttpMethod.Post
|
||||
|| method == HttpMethod.Put
|
||||
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HttpContent CreateContent(
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray();
|
||||
if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded")))
|
||||
{
|
||||
return new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["account_id"] = session.PlayerId.ToString(),
|
||||
["AccountId"] = session.PlayerId.ToString(),
|
||||
["clubId"] = "1",
|
||||
["ClubId"] = "1",
|
||||
["description"] = "Endpoint contract description",
|
||||
["deviceId"] = session.DeviceId,
|
||||
["grant_type"] = "password",
|
||||
["image"] = "endpoint-contract.png",
|
||||
["imageName"] = "endpoint-contract.png",
|
||||
["fileName"] = "endpoint-contract.png",
|
||||
["message"] = "Endpoint contract message",
|
||||
["name"] = "EndpointContract",
|
||||
["newPassword"] = "endpoint-contract-password",
|
||||
["oldPassword"] = "",
|
||||
["password"] = "endpoint-contract-password",
|
||||
["ping"] = "42",
|
||||
["platformId"] = session.PlatformId,
|
||||
["playerId"] = session.PlayerId.ToString(),
|
||||
["playerIds"] = session.PlayerId.ToString(),
|
||||
["query"] = "DormRoom",
|
||||
["region"] = "us",
|
||||
["targetPlayerId"] = session.PlayerId.ToString(),
|
||||
["text"] = "Endpoint contract message",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = session.DeviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = session.PlatformId,
|
||||
["Ids"] = session.PlayerId.ToString(),
|
||||
["PlayerIds"] = session.PlayerId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("multipart/form-data")))
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
content.Add(new StringContent("endpoint-contract"), "name");
|
||||
content.Add(new StringContent(session.PlayerId.ToString()), "playerId");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "imageName");
|
||||
content.Add(new StringContent("endpoint-contract.png"), "fileName");
|
||||
content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin");
|
||||
return content;
|
||||
}
|
||||
|
||||
if (consumes.Any(c => c.Contains("text/plain")))
|
||||
{
|
||||
return new StringContent("", Encoding.UTF8, "text/plain");
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
PlayerId = session.PlayerId,
|
||||
AccountId = session.PlayerId,
|
||||
ClubId = 1,
|
||||
Description = "Endpoint contract description",
|
||||
DeviceId = session.DeviceId,
|
||||
FileName = "endpoint-contract.png",
|
||||
Id = session.PlayerId,
|
||||
Image = "endpoint-contract.png",
|
||||
ImageName = "endpoint-contract.png",
|
||||
Message = "Endpoint contract message",
|
||||
Platform = 0,
|
||||
PlatformId = session.PlatformId,
|
||||
PlayerIds = new[] { session.PlayerId },
|
||||
Name = "EndpointContract",
|
||||
Password = "endpoint-contract-password",
|
||||
Query = "DormRoom",
|
||||
RoomName = "DormRoom",
|
||||
TargetPlayerId = session.PlayerId,
|
||||
Text = "Endpoint contract message",
|
||||
});
|
||||
return new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
private static string AddClientQuery(
|
||||
string path,
|
||||
EndpointContract route,
|
||||
GameClientSession session)
|
||||
{
|
||||
var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
|
||||
var lower = path.ToLowerInvariant();
|
||||
var query = new List<string>();
|
||||
|
||||
if (lower.Contains("search", StringComparison.Ordinal)
|
||||
|| lower.Contains("query", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("query=EndpointContract");
|
||||
}
|
||||
|
||||
if (lower == "/members/bulk")
|
||||
{
|
||||
query.Add("clubId=1");
|
||||
query.Add($"playerIds={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (lower == "/player/photonregionpings")
|
||||
{
|
||||
query.Add("region=us");
|
||||
query.Add("ping=42");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("CachedLogins", StringComparison.Ordinal)
|
||||
|| lower.Contains("platformlogin/cached", StringComparison.Ordinal)
|
||||
|| lower.Contains("savedlogins", StringComparison.Ordinal))
|
||||
{
|
||||
query.Add("platform=0");
|
||||
query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}");
|
||||
}
|
||||
|
||||
if (route.Source.Contains("Bulk", StringComparison.Ordinal)
|
||||
&& !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
query.Add($"Ids={session.PlayerId}");
|
||||
}
|
||||
|
||||
if (query.Count == 0)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + separator + string.Join("&", query);
|
||||
}
|
||||
|
||||
private static string HostFor(string path, string apexDomain)
|
||||
{
|
||||
var lower = path.ToLowerInvariant();
|
||||
|
||||
if (lower.StartsWith("/connect/")
|
||||
|| lower.StartsWith("/.well-known/")
|
||||
|| lower.StartsWith("/eac/")
|
||||
|| lower.StartsWith("/cachedlogin/")
|
||||
|| lower.StartsWith("/photon/")
|
||||
|| lower.StartsWith("/api/platformlogin/")
|
||||
|| lower.StartsWith("/api/version")
|
||||
|| lower.StartsWith("/api/auth/"))
|
||||
{
|
||||
return $"auth.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/account/"))
|
||||
{
|
||||
return $"accounts.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/roomserver/")
|
||||
|| lower.StartsWith("/rooms")
|
||||
|| lower.StartsWith("/playlists")
|
||||
|| lower.StartsWith("/featuredrooms")
|
||||
|| lower.StartsWith("/hot_rooms")
|
||||
|| lower.StartsWith("/search_rooms")
|
||||
|| lower.StartsWith("/api/rooms")
|
||||
|| lower.StartsWith("/api/roomkeys")
|
||||
|| lower.StartsWith("/api/gamesessions")
|
||||
|| lower.StartsWith("/api/curatedroomplaylists"))
|
||||
{
|
||||
return $"rooms.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/find")
|
||||
|| lower.StartsWith("/v1/join")
|
||||
|| lower.StartsWith("/v1/leave")
|
||||
|| lower.StartsWith("/v1/session")
|
||||
|| lower.StartsWith("/roominstance/")
|
||||
|| lower.StartsWith("/goto/")
|
||||
|| lower.StartsWith("/player/"))
|
||||
{
|
||||
return $"match.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/thread"))
|
||||
{
|
||||
return $"chat.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/club")
|
||||
|| lower.StartsWith("/announcements/")
|
||||
|| lower.StartsWith("/subscription/")
|
||||
|| lower.StartsWith("/members/")
|
||||
|| lower.StartsWith("/api/clubreporting/"))
|
||||
{
|
||||
return $"clubs.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/storefronts/")
|
||||
|| lower.StartsWith("/api/purchase/")
|
||||
|| lower.StartsWith("/api/subscription/"))
|
||||
{
|
||||
return $"commerce.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/"))
|
||||
{
|
||||
return $"notify.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/regions"))
|
||||
{
|
||||
return $"geo.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping"))
|
||||
{
|
||||
return $"ns.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/upload"))
|
||||
{
|
||||
return $"storage.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/"))
|
||||
{
|
||||
return $"cdn.{apexDomain}";
|
||||
}
|
||||
|
||||
if (lower.StartsWith("/api/admin/"))
|
||||
{
|
||||
return $"admin.{apexDomain}";
|
||||
}
|
||||
|
||||
return $"api.{apexDomain}";
|
||||
}
|
||||
|
||||
private static string? Excerpt(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var compact = body.ReplaceLineEndings(" ").Trim();
|
||||
return compact.Length <= 240 ? compact : compact[..240] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EndpointProbeResult(
|
||||
string Method,
|
||||
string Host,
|
||||
string Path,
|
||||
string Expected,
|
||||
int? StatusCode,
|
||||
string? Error,
|
||||
string? ResponseExcerpt,
|
||||
bool Passed)
|
||||
{
|
||||
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
public sealed record GameClientSession(
|
||||
long PlayerId,
|
||||
string AccessToken,
|
||||
string DeviceId,
|
||||
string PlatformId);
|
||||
|
||||
public static class GameClientSessionFactory
|
||||
{
|
||||
public static async Task<GameClientSession> CreateAsync(
|
||||
HttpClient client,
|
||||
string apexDomain)
|
||||
{
|
||||
var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}";
|
||||
var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}";
|
||||
var tokenRequest = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
new Uri($"http://auth.{apexDomain}/connect/token"));
|
||||
tokenRequest.Headers.Accept.ParseAdd("application/json");
|
||||
tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
tokenRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = "EndpointContract",
|
||||
["device_id"] = deviceId,
|
||||
["platform"] = "0",
|
||||
["platform_id"] = platformId,
|
||||
});
|
||||
|
||||
using var tokenResponse = await client.SendAsync(tokenRequest);
|
||||
if (!tokenResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await tokenResponse.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
using var tokenJson = await JsonDocument.ParseAsync(
|
||||
await tokenResponse.Content.ReadAsStreamAsync());
|
||||
var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("Token response did not include access_token.");
|
||||
|
||||
var userInfoRequest = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
new Uri($"http://auth.{apexDomain}/connect/userinfo"));
|
||||
userInfoRequest.Headers.Accept.ParseAdd("application/json");
|
||||
userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18");
|
||||
userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18");
|
||||
userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var userInfoResponse = await client.SendAsync(userInfoRequest);
|
||||
userInfoResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var userInfoJson = await JsonDocument.ParseAsync(
|
||||
await userInfoResponse.Content.ReadAsStreamAsync());
|
||||
var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!);
|
||||
|
||||
return new GameClientSession(playerId, accessToken, deviceId, platformId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user