// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
namespace Printing.NativeAuth;
/// Small bounded fixture transport; no redirects, proxy or response-body diagnostics.
internal sealed class FixtureHttp : IDisposable
{
/// Carry only the parsed bounded response and optional redirect location.
internal sealed record Reply(HttpStatusCode Status, JsonObject? Json, Uri? Location);
/// Record fixed classifications without credentials, URLs, or response bodies.
internal sealed record TraceEntry(string Kind, int Status, bool HasQuery, string? LocationKind, bool LocationHasQuery);
internal static List Trace { get; } = [];
internal static string? LastRequestKind
{
get;
private set;
}
internal static int? LastStatus
{
get;
private set;
}
private readonly HttpClient _client;
private readonly CookieContainer _cookies = new();
private readonly Uri _origin;
private readonly string? _adminToken;
/// Configure bounded same-origin HTTP with redirects and proxies disabled.
internal FixtureHttp(Uri origin, bool cookies = false, string? adminToken = null)
{
_origin = origin;
_adminToken = adminToken;
_client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false, UseCookies = cookies, CookieContainer = _cookies })
{
Timeout = TimeSpan.FromSeconds(15),
MaxResponseContentBufferSize = 524288,
};
}
/// Send one origin-confined request and retain bounded sanitized diagnostics.
internal async Task SendAsync(string method, Uri uri, JsonObject? body, CancellationToken cancellationToken)
{
if(!string.Equals(uri.Scheme, "https", StringComparison.Ordinal) || !string.Equals(uri.GetLeftPart(UriPartial.Authority), _origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
{
throw new InvalidOperationException("A fixture request left its configured HTTPS origin.");
}
using var request = new HttpRequestMessage(new HttpMethod(method), uri);
// Fixed classifications only: never retain a query, callback, identifier or body.
LastRequestKind = Classify(uri, method);
LastStatus = null;
if(_adminToken is not null)
{
if(!uri.AbsolutePath.StartsWith("/api/v3/", StringComparison.Ordinal))
{
throw new InvalidOperationException("Administrative credential scope refused.");
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken);
}
if(body is not null)
{
request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
request.Headers.Add("Origin", _origin.GetLeftPart(UriPartial.Authority));
request.Headers.Referrer = _origin;
Cookie? csrf = _cookies.GetCookies(_origin).Cast().SingleOrDefault(cookie => string.Equals(cookie.Name, "authentik_csrf", StringComparison.Ordinal));
csrf ??= _cookies.GetCookies(_origin).Cast().SingleOrDefault(cookie => string.Equals(cookie.Name, "csrftoken", StringComparison.Ordinal));
if(csrf is not null)
{
request.Headers.Add("X-authentik-CSRF", csrf.Value);
}
}
using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken);
LastStatus = (int)response.StatusCode;
Uri? location = response.Headers.Location is null ? null : new Uri(_origin, response.Headers.Location);
if(Trace.Count == 32)
{
Trace.RemoveAt(0);
}
Trace.Add(new TraceEntry(LastRequestKind, (int)response.StatusCode, uri.Query.Length != 0,
location is null ? null : Classify(location, "GET"), location?.Query.Length > 0));
string content = await response.Content.ReadAsStringAsync(cancellationToken);
JsonObject? json = string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.Ordinal) && content.Length != 0
? JsonNode.Parse(content)?.AsObject() : null;
return new Reply(response.StatusCode, json, response.Headers.Location);
}
/// Send one origin-confined request and retain bounded sanitized diagnostics.
internal Task SendAsync(string method, string path, JsonObject? body, CancellationToken cancellationToken)
=> SendAsync(method, new Uri(_origin, path), body, cancellationToken);
/// Map an endpoint to a fixed label without exposing its path or query.
private static string Classify(Uri uri, string method) => string.Equals(uri.Host, "callback.printing.internal", StringComparison.Ordinal) ? "callback"
: uri.AbsolutePath.StartsWith("/application/o/authorize", StringComparison.Ordinal) ? "provider-authorization"
: uri.AbsolutePath.StartsWith("/if/flow/", StringComparison.Ordinal) ? "provider-flow-page"
: uri.AbsolutePath.StartsWith("/api/v3/flows/executor/", StringComparison.Ordinal) ? "provider-flow-executor-" + method
: "fixture-api";
/// Require the expected status and a parsed JSON object.
internal static JsonObject RequireJson(Reply reply, HttpStatusCode expected)
{
if(reply.Status != expected || reply.Json is null)
{
throw new InvalidOperationException("Unexpected fixture API status or document shape.");
}
return reply.Json;
}
/// Dispose the owned HTTP client and handler.
public void Dispose() => _client.Dispose();
}