using System.Net;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api.Tests;
///
/// The wire contract: what goes out, what comes back, and how failures surface.
///
///
/// The problem-code assertions matter most. Those codes are how a client decides what to do next —
/// enrollment-required means enroll, vault-conflict means merge and retry — so losing one
/// while parsing an error turns an actionable failure into an opaque one.
///
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;
///
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(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(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], 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], 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(async () =>
await Client().SyncPushAsync(
VaultId, new SyncPushRequest([]), TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.VaultConflict);
}
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: []);
}