NeverFiveHundredTests walks every endpoint the server maps and fills each route parameter
with junk ('x', zeroed and random ObjectIds, a dot-dot segment, 5000 characters). It calls
each one anonymously, as a persona, and with a junk token, and asserts that nothing
answers 5xx or throws, and that every /api error carries a Mastodon error body.
It found two bugs:
- A junk 'Authorization: Bearer' on any non-/api route, anonymous ones included
(/build.json, /peasants/*, inboxes), broke the response. JwtEvents.AuthenticationFailed
wrote a body during authentication and the endpoint then wrote its own. The 401 body now
comes from the Challenge event, which runs only when an endpoint needs a user.
- /api answered 401, 404 and 429 with no body. UseMastodonErrorBodies gives any /api error
that leaves without a body Mastodon's {"error": ...}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
128 lines
4.3 KiB
C#
128 lines
4.3 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.AspNetCore.Routing.Patterns;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
using PrivaPub.Tests.Support;
|
|
using PrivaPub.Tests.Support.Host;
|
|
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Http
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class NeverFiveHundredTests : IAsyncLifetime
|
|
{
|
|
static readonly string[] Junk = { "x", "000000000000000000000000", "%2e%2e", new string('a', 5000), "5f0c1a2b3c4d5e6f70819203" };
|
|
|
|
PrivaPubHost _host;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_host = await PrivaPubHost.Shared();
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
|
|
public static TheoryData<string> Credentials() => new() { "anonymous", "persona", "junk" };
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Credentials))]
|
|
public async Task No_route_answers_500_to_junk(string credentials)
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var bearer = credentials switch
|
|
{
|
|
"persona" => await _host.MastodonToken(await _host.Persona(await _host.SignUp(), "junk")),
|
|
"junk" => "not-a-token",
|
|
_ => default
|
|
};
|
|
var jwt = credentials == "persona" ? (await _host.SignUp()).Jwt : bearer;
|
|
var failures = new List<string>();
|
|
|
|
foreach (var (method, template) in Routes())
|
|
foreach (var value in Junk)
|
|
{
|
|
var path = Fill(template, value);
|
|
using var request = new HttpRequestMessage(new HttpMethod(method), path);
|
|
request.Headers.Add(PrivaPubHost.ClientHeader, $"10.9.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}");
|
|
var auth = path.StartsWith("/api/", StringComparison.Ordinal) || path.StartsWith("/oauth/", StringComparison.Ordinal) ? bearer : jwt;
|
|
if (auth != default)
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", auth);
|
|
if (method is "POST" or "PUT" or "PATCH")
|
|
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
|
|
using var client = _host.Client();
|
|
HttpResponseMessage response;
|
|
try
|
|
{
|
|
response = await client.SendAsync(request, token);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failures.Add($"{method} {Short(path)} threw {ex.GetType().Name}: {ex.Message}");
|
|
continue;
|
|
}
|
|
using (response)
|
|
{
|
|
if ((int)response.StatusCode >= 500)
|
|
failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode}");
|
|
else if (path.StartsWith("/api/", StringComparison.Ordinal) && (int)response.StatusCode >= 400 && !await IsMastodonError(response))
|
|
failures.Add($"{method} {Short(path)} answered {(int)response.StatusCode} without a Mastodon error body");
|
|
}
|
|
}
|
|
|
|
Assert.True(failures.Count == 0, $"{failures.Count} problems as {credentials}:\n" + string.Join("\n", failures.Distinct().Take(80)));
|
|
}
|
|
|
|
IEnumerable<(string Method, string Template)> Routes()
|
|
{
|
|
var endpoints = _host.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
|
|
foreach (var endpoint in endpoints)
|
|
{
|
|
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods;
|
|
var template = "/" + endpoint.RoutePattern.RawText?.TrimStart('/');
|
|
foreach (var method in methods is { Count: > 0 } ? methods : new[] { "GET", "POST" })
|
|
yield return (method, template);
|
|
}
|
|
}
|
|
|
|
static string Fill(string template, string value)
|
|
{
|
|
var pattern = RoutePatternFactory.Parse(template);
|
|
var builder = new StringBuilder();
|
|
foreach (var segment in pattern.PathSegments)
|
|
{
|
|
builder.Append('/');
|
|
foreach (var part in segment.Parts)
|
|
builder.Append(part switch
|
|
{
|
|
RoutePatternLiteralPart literal => literal.Content,
|
|
RoutePatternParameterPart => value,
|
|
RoutePatternSeparatorPart separator => separator.Content,
|
|
_ => string.Empty
|
|
});
|
|
}
|
|
return builder.Length == 0 ? "/" : builder.ToString();
|
|
}
|
|
|
|
static async Task<bool> IsMastodonError(HttpResponseMessage response)
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
try
|
|
{
|
|
return JsonNode.Parse(body) is JsonObject error && error["error"] is JsonValue;
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static string Short(string path) => path.Length > 120 ? path[..120] + "…" : path;
|
|
}
|
|
}
|