using System.Net; using System.Net.Http.Json; using System.Text.Json; using Xunit; namespace Azaion.Missions.E2E.Helpers; /// /// Reusable HTTP-shape assertions: PascalCase JSON keys, the /// { error, traceId } error envelope, paginated-response shape, and /// expected-status helpers. /// public static class HttpAssertions { public static async Task AssertStatusAsync(HttpResponseMessage response, HttpStatusCode expected) { if (response.StatusCode != expected) { var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); Assert.Fail($"Expected HTTP {(int)expected}; got {(int)response.StatusCode}. Body:\n{body}"); } } public static async Task AssertErrorEnvelopeAsync(HttpResponseMessage response) { var body = await response.Content.ReadFromJsonAsync().ConfigureAwait(false); Assert.True(body.TryGetProperty("error", out _), "error-envelope missing 'error' property"); Assert.True(body.TryGetProperty("traceId", out _), "error-envelope missing 'traceId' property"); AssertNoStackLeak(body); } /// /// Asserts the {statusCode, message} envelope produced by /// ErrorHandlingMiddleware. The envelope uses camelCase keys /// because the middleware emits an anonymous object literal — see /// _docs/02_document/components/06_http_conventions/description.md. /// public static async Task AssertProblemEnvelopeAsync( HttpResponseMessage response, HttpStatusCode expectedStatus) { await AssertStatusAsync(response, expectedStatus).ConfigureAwait(false); var body = await response.Content.ReadFromJsonAsync().ConfigureAwait(false); Assert.True(body.TryGetProperty("statusCode", out var statusEl), "problem envelope missing 'statusCode' property"); Assert.True(body.TryGetProperty("message", out var messageEl), "problem envelope missing 'message' property"); Assert.Equal((int)expectedStatus, statusEl.GetInt32()); var message = messageEl.GetString(); Assert.False(string.IsNullOrEmpty(message), "problem envelope 'message' must be non-empty"); AssertNoStackLeak(body); // Reject any extra keys to pin the envelope contract — the spec says // EXACTLY these two keys (results_report.md row 1.8 + AC-8.6). var extraKeys = body.EnumerateObject() .Select(p => p.Name) .Where(n => n is not ("statusCode" or "message")) .ToArray(); Assert.True(extraKeys.Length == 0, $"problem envelope has unexpected extra keys: {string.Join(",", extraKeys)}"); return new ProblemDto(statusEl.GetInt32(), message!); } public static void AssertNoStackLeak(JsonElement body) { // Walk the JSON DOM and fail if any key looks like it leaks server internals. var leakKeys = new[] { "stack", "stackTrace", "exception", "inner", "trace", "innerException", "type", "details" }; WalkAndAssert(body, leakKeys); } private static void WalkAndAssert(JsonElement element, string[] leakKeys) { switch (element.ValueKind) { case JsonValueKind.Object: foreach (var prop in element.EnumerateObject()) { foreach (var leak in leakKeys) { if (string.Equals(prop.Name, leak, StringComparison.OrdinalIgnoreCase)) Assert.Fail($"error envelope leaks server internals via key '{prop.Name}'"); } WalkAndAssert(prop.Value, leakKeys); } break; case JsonValueKind.Array: foreach (var item in element.EnumerateArray()) WalkAndAssert(item, leakKeys); break; } } public static AuthenticationHeaderValueLike Bearer(string jwt) => new(jwt); public sealed record AuthenticationHeaderValueLike(string Jwt) { public override string ToString() => $"Bearer {Jwt}"; } }