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], [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(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: []); }