Files
jaap-jan f7c5096bc6 Keep the stub servers on loopback
Running the tests raised a Windows Firewall prompt, and raised it again from
every worktree. WireMockServer.Start() with no settings listens on 0.0.0.0
and [::], and the prompt is keyed to the binary that opened the socket — so
each test executable asks once per bin path, which a new worktree or a switch
between Debug and Release makes new again. The three suites that hold a
firewall rule on this machine are exactly the three that use WireMock; every
other listener in the repository already binds 127.0.0.1.

The stubs now say so explicitly. Port 0 is still WireMock's own free-port
search and still comes back on server.Url, which is what each stub builds its
base URL from, so the authority the API validates against and the issuer its
tokens claim follow the binding rather than being pinned to a host name.

Sampling the listening sockets of a full DodoSSH.Api.Tests run afterwards
finds one, 127.0.0.1, where there were previously three.
2026-07-31 11:06:46 +02:00

134 lines
5.2 KiB
C#

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;
/// <summary>A stand-in DodoSSH server.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class StubServer : IDisposable
{
/// <remarks>
/// 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
/// <see cref="WireMockServer.Url"/>.
/// </remarks>
private readonly WireMockServer server = WireMockServer.Start(
new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
internal Uri BaseUrl => new(server.Url!, UriKind.Absolute);
/// <summary>Requests received, so tests can assert on what was sent.</summary>
internal IReadOnlyList<WireMock.Logging.ILogEntry> 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));
/// <summary>Stubs an RFC 9457 problem response.</summary>
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());
}
/// <summary>Stubs a non-JSON error, as a reverse proxy in front of a dead server would return.</summary>
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("<html><body><h1>502 Bad Gateway</h1></body></html>"));
/// <summary>The body of the last request to a path.</summary>
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;
}
/// <summary>The Authorization header of the last request to a path.</summary>
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;
}
/// <inheritdoc />
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));
}
/// <summary>Hands out a fixed token, so tests can assert it reached the wire.</summary>
internal sealed class StubTokenProvider(string token = "test-access-token") : IAccessTokenProvider
{
/// <inheritdoc />
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(token);
}