using System.Text.Json; using System.Text.Json.Nodes; using DodoSSH.Contracts; using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; using WireMock.Settings; namespace DodoSSH.Client.Api.Tests; /// A stand-in DodoSSH server. /// /// Responses are built from the real contract types and the real source-generated serialiser, so the /// client is reading exactly the shape a live server produces rather than hand-written JSON that /// happens to satisfy it. /// internal sealed class StubServer : IDisposable { /// /// Bound to loopback explicitly. WireMock's default listens on every interface, which makes Windows /// Firewall prompt the first time each test executable runs — and the prompt is per binary path, so a /// new worktree or configuration asks again. Port 0 still picks a free port and reports it on /// . /// private readonly WireMockServer server = WireMockServer.Start( new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] }); internal Uri BaseUrl => new(server.Url!, UriKind.Absolute); /// Requests received, so tests can assert on what was sent. internal IReadOnlyList Requests => server.LogEntries.ToList(); internal void StubMe(MeResponse response) => StubJson("/api/v1/me", "GET", 200, JsonSerializer.Serialize( response, DodoSshJsonContext.Default.MeResponse)); internal void StubEnrollment(EnrollmentResponse response) => StubJson("/api/v1/me/enrollment", "POST", 200, JsonSerializer.Serialize( response, DodoSshJsonContext.Default.EnrollmentResponse)); internal void StubMeta(MetaResponse response) => StubJson("/api/v1/meta", "GET", 200, JsonSerializer.Serialize( response, DodoSshJsonContext.Default.MetaResponse)); internal void StubPush(Guid vaultId, SyncPushResponse response) => StubJson($"/api/v1/vaults/{vaultId}/sync/push", "POST", 200, JsonSerializer.Serialize( response, DodoSshJsonContext.Default.SyncPushResponse)); internal void StubPull(Guid vaultId, SyncPullResponse response) => StubJson($"/api/v1/vaults/{vaultId}/sync/pull", "POST", 200, JsonSerializer.Serialize( response, DodoSshJsonContext.Default.SyncPullResponse)); /// Stubs an RFC 9457 problem response. internal void StubProblem(string path, string method, int statusCode, string code, string detail) { var problem = new JsonObject { ["type"] = ProblemCodes.TypeBaseUri + code, ["title"] = "Request failed", ["status"] = statusCode, ["detail"] = detail, ["code"] = code, }; StubJson(path, method, statusCode, problem.ToJsonString()); } /// Stubs a non-JSON error, as a reverse proxy in front of a dead server would return. internal void StubGatewayError(string path, string method) => server .Given(Request.Create().WithPath(path).UsingMethod(method)) .RespondWith(Response.Create() .WithStatusCode(502) .WithHeader("Content-Type", "text/html") .WithBody("

502 Bad Gateway

")); /// The body of the last request to a path. internal string LastBody(string path) { var entries = server.LogEntries .Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true) .ToList(); if (entries.Count == 0) { throw new InvalidOperationException($"Nothing was sent to {path}."); } return entries[^1].RequestMessage?.Body ?? string.Empty; } /// The Authorization header of the last request to a path. internal string? LastAuthorization(string path) { var entries = server.LogEntries .Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true) .ToList(); if (entries.Count == 0) { return null; } var headers = entries[^1].RequestMessage?.Headers; return headers is not null && headers.TryGetValue("Authorization", out var values) ? values.FirstOrDefault() : null; } /// public void Dispose() { server.Stop(); server.Dispose(); } private void StubJson(string path, string method, int statusCode, string body) => server .Given(Request.Create().WithPath(path).UsingMethod(method)) .RespondWith(Response.Create() .WithStatusCode(statusCode) .WithHeader("Content-Type", "application/json") .WithBody(body)); } /// Hands out a fixed token, so tests can assert it reached the wire. internal sealed class StubTokenProvider(string token = "test-access-token") : IAccessTokenProvider { /// public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken) => ValueTask.FromResult(token); }