using System.Text.Json;
using System.Text.Json.Nodes;
using DodoSSH.Contracts;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
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
{
private readonly WireMockServer server = WireMockServer.Start();
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);
}