Public Access
The relay checkbox said it was not wired up, and the plan said the server half was built and shipped. It was not: there was ADR 0004, the contract records, RelayOptions and a CHECK constraint, and RelayOptions said in its own remarks that there was no endpoint and no dialler. So both halves are built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md. Server, in Features/Relay: - POST /api/v1/relay/tickets makes every decision: the host, Connect on its vault, the relay switched on, and the stored name resolved once and judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8, multicast and the metadata endpoints are refused with no configuration escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the operator's denials, allow-list and private-network switch. What passes goes into a Data Protection ticket, and nothing resolves the name again. Every target refusal is the same 403, so the endpoint cannot be used to map the operator's DNS; the real reason is logged. - GET /api/v1/relay/connect takes the ticket as a second subprotocol, checks its expiry against TimeProvider and spends it with a unique insert into relay_ticket_use before accepting the upgrade. After the upgrade a refusal is a close code: 4502 for a host the server could not reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain. - relay_session is the audit row, written from short-lived scopes. Client: - RelayBridge is a loopback SOCKS5 listener that accepts once and pipes to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so the connection disposes it on every way a session ends and a failed connect disposes it before throwing. - ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for the ticket, opens the socket, starts the bridge and turns every refusal into a sentence. - VaultViewModel and TransfersViewModel reach a relayed host through it, never fall back to dialling it directly, and replace SSH.NET's transport error with the relay's own reason when it gave one. Neither side ever cancels a WebSocket operation. Cancelling one aborts the socket, and an aborted socket sends no close frame, which is the only way the client learns the server could not reach the host. AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives. Three departures from ADR 0004, recorded in it: awaited copy loops rather than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket= fallback, and no device id in the ticket. Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a real login through the bridge, API client 30, contracts 25. Not run: the Android build (its change is copy only) and the Alpine layout oracle.
277 lines
10 KiB
C#
277 lines
10 KiB
C#
using System.Net;
|
|
using DodoSSH.Contracts;
|
|
|
|
namespace DodoSSH.Client.Api.Tests;
|
|
|
|
/// <summary>
|
|
/// The wire contract: what goes out, what comes back, and how failures surface.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The problem-code assertions matter most. Those codes are how a client decides what to do next —
|
|
/// <c>enrollment-required</c> means enroll, <c>vault-conflict</c> means merge and retry — so losing one
|
|
/// while parsing an error turns an actionable failure into an opaque one.
|
|
/// </remarks>
|
|
public sealed class DodoSshApiClientTests : IDisposable
|
|
{
|
|
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
|
|
|
private readonly StubServer server = new();
|
|
private readonly HttpClient http = new();
|
|
|
|
public DodoSshApiClientTests() => http.BaseAddress = server.BaseUrl;
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
http.Dispose();
|
|
server.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Meta_IsFetchedWithoutAToken()
|
|
{
|
|
// A client has to be able to ask what a server supports before it can authenticate, so this
|
|
// one must not require a bearer token.
|
|
server.StubMeta(new MetaResponse(
|
|
ServerVersion: "1.2.3",
|
|
ApiVersions: [1],
|
|
SyncProtocolVersion: 1,
|
|
CryptoSpecVersion: 1,
|
|
Features: ["teams"],
|
|
MinClientVersion: null,
|
|
MaxOperationsPerPush: 500,
|
|
MaxPayloadBytes: 8 * 1024 * 1024,
|
|
MaxItemPayloadBytes: 256 * 1024));
|
|
|
|
var meta = await Client().GetMetaAsync(TestContext.Current.CancellationToken);
|
|
|
|
meta.ServerVersion.ShouldBe("1.2.3");
|
|
meta.MaxOperationsPerPush.ShouldBe(500);
|
|
|
|
server.LastAuthorization("/api/v1/meta").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Me_CarriesTheBearerToken()
|
|
{
|
|
server.StubMe(UnenrolledMe());
|
|
|
|
var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);
|
|
|
|
me.EnrollmentRequired.ShouldBeTrue();
|
|
server.LastAuthorization("/api/v1/me").ShouldBe("Bearer test-access-token");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Me_RoundTripsVaultSummaries()
|
|
{
|
|
// Byte arrays through the source-generated serialiser, which is the one thing most likely to
|
|
// go wrong silently between the two sides.
|
|
var wrappedKey = new byte[] { 1, 2, 3, 4, 5 };
|
|
|
|
server.StubMe(UnenrolledMe() with
|
|
{
|
|
EnrollmentRequired = false,
|
|
KeyGeneration = 1,
|
|
WrappedPrivateKey = [9, 8, 7],
|
|
KdfParameters = new KdfParameters("argon2id", [1, 2, 3], 262144, 4, 1),
|
|
Vaults =
|
|
[
|
|
new VaultSummary(
|
|
VaultId: VaultId,
|
|
Name: "Personal",
|
|
IsPersonal: true,
|
|
TeamId: null,
|
|
KeyGeneration: 1,
|
|
Permissions: 31,
|
|
WrappedVaultKey: wrappedKey,
|
|
RekeyRequired: false),
|
|
],
|
|
});
|
|
|
|
var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);
|
|
|
|
var vault = me.Vaults.ShouldHaveSingleItem();
|
|
vault.WrappedVaultKey.ShouldBe(wrappedKey);
|
|
vault.KeyGeneration.ShouldBe(1u);
|
|
me.WrappedPrivateKey.ShouldBe([9, 8, 7]);
|
|
me.KdfParameters!.MemoryKibibytes.ShouldBe(262144);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AProblemResponse_KeepsItsCode()
|
|
{
|
|
server.StubProblem(
|
|
"/api/v1/me", "GET", 403, ProblemCodes.EnrollmentRequired, "Publish an identity key first.");
|
|
|
|
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
|
|
await Client().GetMeAsync(TestContext.Current.CancellationToken));
|
|
|
|
exception.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
|
|
exception.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
|
exception.Message.ShouldContain("Publish an identity key first.");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ANonJsonError_StillReportsItsStatus()
|
|
{
|
|
// A reverse proxy in front of a dead server returns HTML. Losing the status code while trying
|
|
// to parse that as ProblemDetails would replace a diagnosable failure with a parse error.
|
|
server.StubGatewayError("/api/v1/me", "GET");
|
|
|
|
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
|
|
await Client().GetMeAsync(TestContext.Current.CancellationToken));
|
|
|
|
exception.StatusCode.ShouldBe(HttpStatusCode.BadGateway);
|
|
exception.Code.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Push_SendsTheBatchAndReturnsPerOperationStatus()
|
|
{
|
|
// A push succeeds with mixed results on purpose, so one stale item cannot block everything
|
|
// else a client queued while offline. Callers must read the statuses rather than trusting
|
|
// the 200.
|
|
var applied = Guid.CreateVersion7();
|
|
var conflicted = Guid.CreateVersion7();
|
|
|
|
server.StubPush(VaultId, new SyncPushResponse(
|
|
Results:
|
|
[
|
|
new SyncPushResult(applied, SyncOperationStatus.Applied, 1, 10, null, null),
|
|
new SyncPushResult(conflicted, SyncOperationStatus.Conflict, 2, 11, null, null),
|
|
],
|
|
Cursor: "next-cursor"));
|
|
|
|
var request = new SyncPushRequest(
|
|
[
|
|
new SyncPushOperation(
|
|
applied,
|
|
SyncEntityType.Host,
|
|
Guid.CreateVersion7(),
|
|
SyncOperation.Upsert,
|
|
null,
|
|
new EncryptedPayload([1, 2, 3], [7, 7], Guid.CreateVersion7(), 1, 1),
|
|
new SyncPlaintextFields()),
|
|
]);
|
|
|
|
var response = await Client().SyncPushAsync(
|
|
VaultId, request, TestContext.Current.CancellationToken);
|
|
|
|
response.Results.Count.ShouldBe(2);
|
|
response.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
|
|
response.Results[1].Status.ShouldBe(SyncOperationStatus.Conflict);
|
|
response.Cursor.ShouldBe("next-cursor");
|
|
|
|
var body = server.LastBody($"/api/v1/vaults/{VaultId}/sync/push");
|
|
body.ShouldContain("operations");
|
|
body.ShouldContain("Upsert");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Pull_RoundTripsChangesAndTheCursor()
|
|
{
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
server.StubPull(VaultId, new SyncPullResponse(
|
|
Changes:
|
|
[
|
|
new SyncChange(
|
|
SyncEntityType.Host,
|
|
entityId,
|
|
SyncOperation.Upsert,
|
|
Version: 1,
|
|
ChangeSequence: 5,
|
|
Payload: new EncryptedPayload([4, 5, 6], [8, 8], Guid.CreateVersion7(), 1, 1),
|
|
PlaintextFields: new SyncPlaintextFields(RelayEnabled: false),
|
|
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)),
|
|
],
|
|
NextCursor: "cursor-2",
|
|
HasMore: false,
|
|
ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_001),
|
|
CurrentKeyGeneration: 1));
|
|
|
|
var response = await Client().SyncPullAsync(
|
|
VaultId,
|
|
new SyncPullRequest("cursor-1", null, null),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var change = response.Changes.ShouldHaveSingleItem();
|
|
change.EntityId.ShouldBe(entityId);
|
|
change.Payload!.Envelope.ShouldBe([4, 5, 6]);
|
|
response.NextCursor.ShouldBe("cursor-2");
|
|
response.HasMore.ShouldBeFalse();
|
|
|
|
server.LastBody($"/api/v1/vaults/{VaultId}/sync/pull").ShouldContain("cursor-1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AConflictOnPush_SurfacesItsCode()
|
|
{
|
|
server.StubProblem(
|
|
$"/api/v1/vaults/{VaultId}/sync/push",
|
|
"POST",
|
|
409,
|
|
ProblemCodes.VaultConflict,
|
|
"Stale version.");
|
|
|
|
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
|
|
await Client().SyncPushAsync(
|
|
VaultId, new SyncPushRequest([]), TestContext.Current.CancellationToken));
|
|
|
|
exception.Code.ShouldBe(ProblemCodes.VaultConflict);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARelayTicket_IsAskedForByHostAlone()
|
|
{
|
|
// The request names a host and nothing else. An address in it would be the open proxy ADR 0004
|
|
// exists to prevent, and the server would be right to ignore it — so it is not sent.
|
|
var hostId = Guid.CreateVersion7();
|
|
var issued = new RelayTicketResponse(
|
|
"dGlja2V0",
|
|
new Uri("wss://relay.example" + RelayProtocol.ConnectPath),
|
|
RelayProtocol.SubProtocol,
|
|
Guid.CreateVersion7(),
|
|
new DateTimeOffset(2026, 9, 28, 12, 0, 30, TimeSpan.Zero));
|
|
|
|
server.StubRelayTicket(issued);
|
|
|
|
var ticket = await Client().IssueRelayTicketAsync(hostId, TestContext.Current.CancellationToken);
|
|
|
|
ticket.ShouldBe(issued);
|
|
server.LastAuthorization(RelayProtocol.TicketsPath).ShouldBe("Bearer test-access-token");
|
|
|
|
var body = System.Text.Json.Nodes.JsonNode.Parse(server.LastBody(RelayProtocol.TicketsPath))!.AsObject();
|
|
body["hostId"]!.GetValue<Guid>().ShouldBe(hostId);
|
|
body.Where(property => property.Key is not ("hostId" or "portForwardId")).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARefusedRelayTicket_CarriesItsCode()
|
|
{
|
|
server.StubProblem(
|
|
RelayProtocol.TicketsPath, "POST", 403, ProblemCodes.RelayTargetRejected, "The relay will not connect.");
|
|
|
|
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
|
|
await Client().IssueRelayTicketAsync(Guid.CreateVersion7(), TestContext.Current.CancellationToken));
|
|
|
|
exception.Code.ShouldBe(ProblemCodes.RelayTargetRejected);
|
|
}
|
|
|
|
private DodoSshApiClient Client() => new(http, new StubTokenProvider());
|
|
|
|
private static MeResponse UnenrolledMe() =>
|
|
new(
|
|
UserId: Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"),
|
|
Issuer: "https://idp.example",
|
|
Subject: "alice",
|
|
Email: "alice@example.com",
|
|
DisplayName: "Alice",
|
|
EnrollmentRequired: true,
|
|
KeyGeneration: null,
|
|
WrappedPrivateKey: null,
|
|
KdfParameters: null,
|
|
Vaults: []);
|
|
}
|