using System.Net; using System.Net.Http.Json; using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace DodoSSH.Api.Tests; /// /// End-to-end sync behaviour over HTTP, and the authorization denials. /// /// /// The denial tests are the most important thing here. Every one of them asserts that a caller who /// should not reach a vault does not, through the real authentication pipeline rather than a /// bypassed one. /// [Collection(ApiCollection.Name)] public sealed class SyncEndpointTests(ApiFixture fixture) { private static readonly DateTimeOffset Now = new(2026, 7, 28, 12, 0, 0, TimeSpan.Zero); // ---- Authentication ---- [Fact] public async Task Pull_WithoutAToken_Is401() { var client = fixture.CreateClient(); var response = await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact] public async Task Push_WithoutAToken_Is401() { var client = fixture.CreateClient(); var response = await client.PostAsJsonAsync( PushUrl(Guid.CreateVersion7()), new SyncPushRequest([])); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact] public async Task ATokenSignedByAnotherKey_Is401() { // Proves signature validation is genuinely running, not stubbed out. var client = fixture.CreateClientWithToken( fixture.IdentityProvider.MintTokenWithForeignKey(NewSubject())); var response = await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact] public async Task ATokenForAnotherAudience_Is401() { var client = fixture.CreateClientWithToken( fixture.IdentityProvider.MintToken(NewSubject(), audience: "some-other-api")); var response = await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact] public async Task ATokenFromAnotherIssuer_Is401() { var client = fixture.CreateClientWithToken( fixture.IdentityProvider.MintToken(NewSubject(), issuer: "https://evil.example")); var response = await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact] public async Task AnExpiredToken_Is401() { var client = fixture.CreateClientWithToken(fixture.IdentityProvider.MintToken( NewSubject(), expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddMinutes(-10))); var response = await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } // ---- Authorization: the wrong user must be denied ---- [Fact] public async Task Pull_AnotherUsersVault_Is404() { // 404 rather than 403: a distinct "exists but forbidden" answer would let a caller // enumerate other tenants' vault ids. var (_, vaultId) = await SeedUserWithVaultAsync(); var intruder = fixture.CreateClientFor(NewSubject()); var response = await intruder.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } [Fact] public async Task Push_AnotherUsersVault_Is404() { var (_, vaultId) = await SeedUserWithVaultAsync(); var intruder = fixture.CreateClientFor(NewSubject()); var response = await intruder.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } [Fact] public async Task Push_AnotherUsersVault_WritesNothing() { // A denial that still mutated state would be worse than no check at all. var (_, vaultId) = await SeedUserWithVaultAsync(); var intruder = fixture.CreateClientFor(NewSubject()); var batch = NewCreateBatch(); await intruder.PostAsJsonAsync(PushUrl(vaultId), batch); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); (await database.Hosts.AnyAsync(h => h.VaultId == vaultId)).ShouldBeFalse(); (await database.VaultChanges.AnyAsync(c => c.VaultId == vaultId)).ShouldBeFalse(); } [Fact] public async Task Pull_ANonexistentVault_Is404() { var client = fixture.CreateClientFor(NewSubject()); var response = await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } [Fact] public async Task ATeamVault_IsDeniedUntilTeamsShip() { // Failing closed on an unimplemented path, rather than falling through to a default. var vaultId = await SeedTeamVaultAsync(); var client = fixture.CreateClientFor(NewSubject()); var response = await client.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } // ---- Round trip ---- [Fact] public async Task Push_ThenPull_ReturnsTheItem() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var batch = NewCreateBatch(); var push = await client.PostAsJsonAsync(PushUrl(vaultId), batch); push.EnsureSuccessStatusCode(); var pushed = await push.Content.ReadFromJsonAsync(); pushed.ShouldNotBeNull(); pushed.Results.Count.ShouldBe(1); pushed.Results[0].Status.ShouldBe(SyncOperationStatus.Applied); pushed.Results[0].Version.ShouldBe(1); var pull = await client.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); pull.EnsureSuccessStatusCode(); var pulled = await pull.Content.ReadFromJsonAsync(); pulled.ShouldNotBeNull(); pulled.Changes.Count.ShouldBe(1); var change = pulled.Changes[0]; change.EntityId.ShouldBe(batch.Operations[0].EntityId); change.Operation.ShouldBe(SyncOperation.Upsert); change.Payload.ShouldNotBeNull(); change.Payload.Envelope.ShouldBe(batch.Operations[0].Payload!.Envelope); } [Fact] public async Task Pull_WithACursor_ReturnsOnlyNewerChanges() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); var first = await (await client.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null))).Content.ReadFromJsonAsync(); first.ShouldNotBeNull(); // Nothing new since that cursor. var empty = await (await client.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest(first.NextCursor, null, null))) .Content.ReadFromJsonAsync(); empty.ShouldNotBeNull(); empty.Changes.ShouldBeEmpty(); // The cursor must not have rewound, or the next poll would replay history. empty.NextCursor.ShouldBe(first.NextCursor); await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); var second = await (await client.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest(first.NextCursor, null, null))) .Content.ReadFromJsonAsync(); second.ShouldNotBeNull(); second.Changes.Count.ShouldBe(1); } [Fact] public async Task Pull_WithACursorFromAnotherVault_Is400() { var (subject, firstVault) = await SeedUserWithVaultAsync(); var (_, otherVault) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var pull = await client.PostAsJsonAsync( PullUrl(firstVault), new SyncPullRequest(null, null, null)); var cursor = (await pull.Content.ReadFromJsonAsync())!.NextCursor; // Correctly signed, but issued for a different vault. var response = await client.PostAsJsonAsync( PullUrl(otherVault), new SyncPullRequest(cursor, null, null)); // 404 first, because this caller cannot see the other vault at all. response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } [Fact] public async Task Pull_WithATamperedCursor_Is400() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var response = await client.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest("bm90LWEtcmVhbC1jdXJzb3I", null, null)); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var problem = await response.Content.ReadFromJsonAsync(); problem.ShouldNotBeNull(); problem.Code.ShouldBe(ProblemCodes.InvalidCursor); } // ---- Conflict and idempotency ---- [Fact] public async Task Push_WithAStaleVersion_ReportsConflictAndReturnsServerState() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var create = NewCreateBatch(); var entityId = create.Operations[0].EntityId; await client.PostAsJsonAsync(PushUrl(vaultId), create); // Update to version 2. await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [9, 9, 9]), ])); // A second client still believes it is on version 1. var stale = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [7, 7, 7]), ])); stale.EnsureSuccessStatusCode(); var body = await stale.Content.ReadFromJsonAsync(); body.ShouldNotBeNull(); body.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict); body.Results[0].Version.ShouldBe(2); // The server's current state comes back so the client can merge rather than guess. body.Results[0].ServerEntity.ShouldNotBeNull(); body.Results[0].ServerEntity!.Payload!.Envelope.ShouldBe([9, 9, 9]); } [Fact] public async Task Push_WithAConflict_DoesNotOverwrite() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var create = NewCreateBatch(); var entityId = create.Operations[0].EntityId; await client.PostAsJsonAsync(PushUrl(vaultId), create); await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [9, 9, 9]), ])); await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [7, 7, 7]), ])); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var stored = await database.Hosts.SingleAsync(h => h.Id == entityId); // Never last-writer-wins. stored.Payload.ShouldBe([9, 9, 9]); stored.Version.ShouldBe(2); } [Fact] public async Task Push_ReplayingAnOperationId_IsReportedDuplicateAndAppliedOnce() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var batch = NewCreateBatch(); var first = await client.PostAsJsonAsync(PushUrl(vaultId), batch); first.EnsureSuccessStatusCode(); // Exactly the same batch again, as a retry after a timeout would be. var replay = await client.PostAsJsonAsync(PushUrl(vaultId), batch); replay.EnsureSuccessStatusCode(); var body = await replay.Content.ReadFromJsonAsync(); body.ShouldNotBeNull(); body.Results[0].Status.ShouldBe(SyncOperationStatus.Duplicate); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var stored = await database.Hosts.SingleAsync(h => h.Id == batch.Operations[0].EntityId); stored.Version.ShouldBe(1); (await database.VaultChanges.CountAsync(c => c.EntityId == stored.Id)).ShouldBe(1); } [Fact] public async Task Push_AMixedBatch_AppliesTheGoodAndReportsTheBad() { // One stale item must not block everything else a client queued while offline. var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var existing = NewCreateBatch(); await client.PostAsJsonAsync(PushUrl(vaultId), existing); var goodId = Guid.CreateVersion7(); var mixed = new SyncPushRequest( [ NewOperation(existing.Operations[0].EntityId, expectedVersion: 99, envelope: [1]), NewOperation(goodId, expectedVersion: null, envelope: [2, 2]), ]); var response = await client.PostAsJsonAsync(PushUrl(vaultId), mixed); // 200 despite a failed operation: per-operation status carries the detail. response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); body.ShouldNotBeNull(); body.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict); body.Results[1].Status.ShouldBe(SyncOperationStatus.Applied); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); (await database.Hosts.AnyAsync(h => h.Id == goodId)).ShouldBeTrue(); } // ---- Relay field enforcement, ADR 0004 ---- [Fact] public async Task Push_ARelayAddressWithoutEnablingRelay_IsRejected() { // Prevents the server quietly learning an address the user never opted into exposing. var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, Guid.CreateVersion7(), SyncOperation.Upsert, null, new EncryptedPayload([1, 2, 3], 1, 1), new SyncPlaintextFields(RelayEnabled: false, Hostname: "secret.internal", Port: 22)), ])); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } [Fact] public async Task Push_RelayEnabledWithoutAnAddress_IsRejected() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, Guid.CreateVersion7(), SyncOperation.Upsert, null, new EncryptedPayload([1, 2, 3], 1, 1), new SyncPlaintextFields(RelayEnabled: true)), ])); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } [Fact] public async Task Delete_ClearsTheRelayAddress() { // Leaving it would keep the server able to resolve a host the user believes is gone. var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var entityId = Guid.CreateVersion7(); await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Upsert, null, new EncryptedPayload([1, 2, 3], 1, 1), new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)), ])); await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete, ExpectedVersion: 1, Payload: null, PlaintextFields: null), ])); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var stored = await database.Hosts.SingleAsync(h => h.Id == entityId); stored.DeletedAtUtc.ShouldNotBeNull(); stored.RelayEnabled.ShouldBeFalse(); stored.Hostname.ShouldBeNull(); stored.Port.ShouldBeNull(); } [Fact] public async Task Pull_ADeletedItem_ReturnsATombstoneWithNoPayload() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var create = NewCreateBatch(); var entityId = create.Operations[0].EntityId; await client.PostAsJsonAsync(PushUrl(vaultId), create); await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete, ExpectedVersion: 1, Payload: null, PlaintextFields: null), ])); var pull = await client.PostAsJsonAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); var body = await pull.Content.ReadFromJsonAsync(); body.ShouldNotBeNull(); var tombstone = body.Changes.Last(c => c.EntityId == entityId); tombstone.Operation.ShouldBe(SyncOperation.Delete); tombstone.Payload.ShouldBeNull(); tombstone.PlaintextFields.ShouldBeNull(); } // ---- Batch limits ---- [Fact] public async Task Push_AnEmptyBatch_Is400() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest([])); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } [Fact] public async Task Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch() { // A newer client asking for something this server does not do yet gets a precise // per-operation answer rather than a whole-batch rejection. var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Credential, Guid.CreateVersion7(), SyncOperation.Upsert, null, new EncryptedPayload([1], 1, 1), null), ])); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } // ---- JIT provisioning ---- [Fact] public async Task AFirstRequest_ProvisionsTheUser() { var subject = NewSubject(); var email = $"{subject}@example.com"; var client = fixture.CreateClientFor(subject, email); // Any authenticated call is enough to trigger provisioning. await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var user = await database.Users.SingleOrDefaultAsync(u => u.Subject == subject); user.ShouldNotBeNull(); user.Issuer.ShouldBe(fixture.IdentityProvider.Authority); user.Email.ShouldBe(email); user.Status.ShouldBe(UserStatus.Active); } [Fact] public async Task RepeatedRequests_ProvisionOnlyOnce() { var subject = NewSubject(); var client = fixture.CreateClientFor(subject); for (var i = 0; i < 3; i++) { await client.PostAsJsonAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); } await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); (await database.Users.CountAsync(u => u.Subject == subject)).ShouldBe(1); } // ---- Helpers ---- private static string PullUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/pull"; private static string PushUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/push"; private static string NewSubject() => $"user-{Guid.CreateVersion7():N}"; private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) => new( Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Upsert, expectedVersion, new EncryptedPayload(envelope, 1, 1), new SyncPlaintextFields()); private static SyncPushRequest NewCreateBatch() => new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]); private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync() { var subject = NewSubject(); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var user = new UserAccount { Id = Guid.CreateVersion7(), Issuer = fixture.IdentityProvider.Authority, Subject = subject, Status = UserStatus.Active, CreatedAtUtc = Now, UpdatedAtUtc = Now, }; var vault = new Vault { Id = Guid.CreateVersion7(), Name = "Personal", OwnerKind = VaultOwnerKind.Personal, OwnerUserId = user.Id, KeyGeneration = 1, CreatedAtUtc = Now, UpdatedAtUtc = Now, }; database.Users.Add(user); database.Vaults.Add(vault); await database.SaveChangesAsync(); return (subject, vault.Id); } private async Task SeedTeamVaultAsync() { await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var owner = new UserAccount { Id = Guid.CreateVersion7(), Issuer = fixture.IdentityProvider.Authority, Subject = NewSubject(), Status = UserStatus.Active, CreatedAtUtc = Now, UpdatedAtUtc = Now, }; var team = new Team { Id = Guid.CreateVersion7(), Name = "Team", Slug = $"team-{Guid.CreateVersion7():N}", CreatedByUserId = owner.Id, CreatedAtUtc = Now, }; var vault = new Vault { Id = Guid.CreateVersion7(), Name = "Shared", OwnerKind = VaultOwnerKind.Team, TeamId = team.Id, KeyGeneration = 1, CreatedAtUtc = Now, UpdatedAtUtc = Now, }; database.Users.Add(owner); database.Teams.Add(team); database.Vaults.Add(vault); await database.SaveChangesAsync(); return vault.Id; } /// Minimal ProblemDetails shape, for asserting on the code extension. private sealed record JsonProblem(string? Type, string? Detail, string? Code); }