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 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(); 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().Endpoints.OfType(); foreach (var endpoint in endpoints) { var methods = endpoint.Metadata.GetMetadata()?.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 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; } }