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.PostContractAsync( 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.PostContractAsync( 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.PostContractAsync( 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.PostContractAsync( 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.PostContractAsync( 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.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } // ---- Authorization: the caller must have enrolled ---- [Fact] public async Task Pull_BeforeEnrolling_Is403WithAnActionableCode() { // Not a confidentiality boundary — an unenrolled user owns no vault anyway. The value is // that the client is told what to do instead of receiving an empty 403 or, worse, // ciphertext it has no key for. var client = fixture.CreateClientFor(NewSubject()); var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); var problem = await response.Content.ReadProblemAsync(); problem.ShouldNotBeNull(); problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired); } [Fact] public async Task Push_BeforeEnrolling_Is403AndWritesNothing() { var (_, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(NewSubject()); var response = await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch()); response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); (await database.VaultChanges.AnyAsync(c => c.VaultId == vaultId)).ShouldBeFalse(); } // ---- 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(await SeedEnrolledUserAsync()); var response = await intruder.PostContractAsync( 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(await SeedEnrolledUserAsync()); var response = await intruder.PostContractAsync(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(await SeedEnrolledUserAsync()); var batch = NewCreateBatch(); await intruder.PostContractAsync(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(await SeedEnrolledUserAsync()); var response = await client.PostContractAsync( 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(await SeedEnrolledUserAsync()); var response = await client.PostContractAsync( 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.PostContractAsync(PushUrl(vaultId), batch); push.EnsureSuccessStatusCode(); var pushed = await push.Content.ReadContractAsync(); pushed.ShouldNotBeNull(); pushed.Results.Count.ShouldBe(1); pushed.Results[0].Status.ShouldBe(SyncOperationStatus.Applied); pushed.Results[0].Version.ShouldBe(1); var pull = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); pull.EnsureSuccessStatusCode(); var pulled = await pull.Content.ReadContractAsync(); 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.PostContractAsync(PushUrl(vaultId), NewCreateBatch()); var first = await (await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null))).Content.ReadContractAsync(); first.ShouldNotBeNull(); // Nothing new since that cursor. var empty = await (await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(first.NextCursor, null, null))) .Content.ReadContractAsync(); 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.PostContractAsync(PushUrl(vaultId), NewCreateBatch()); var second = await (await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(first.NextCursor, null, null))) .Content.ReadContractAsync(); 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.PostContractAsync( PullUrl(firstVault), new SyncPullRequest(null, null, null)); var cursor = (await pull.Content.ReadContractAsync())!.NextCursor; // Correctly signed, but issued for a different vault. var response = await client.PostContractAsync( 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.PostContractAsync( PullUrl(vaultId), new SyncPullRequest("bm90LWEtcmVhbC1jdXJzb3I", null, null)); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var problem = await response.Content.ReadProblemAsync(); 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.PostContractAsync(PushUrl(vaultId), create); // Update to version 2. await client.PostContractAsync(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.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [7, 7, 7]), ])); stale.EnsureSuccessStatusCode(); var body = await stale.Content.ReadContractAsync(); 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.PostContractAsync(PushUrl(vaultId), create); await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [9, 9, 9]), ])); await client.PostContractAsync(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.PostContractAsync(PushUrl(vaultId), batch); first.EnsureSuccessStatusCode(); // Exactly the same batch again, as a retry after a timeout would be. var replay = await client.PostContractAsync(PushUrl(vaultId), batch); replay.EnsureSuccessStatusCode(); var body = await replay.Content.ReadContractAsync(); 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.PostContractAsync(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.PostContractAsync(PushUrl(vaultId), mixed); // 200 despite a failed operation: per-operation status carries the detail. response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadContractAsync(); 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.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, Guid.CreateVersion7(), SyncOperation.Upsert, null, Payload([1, 2, 3]), new SyncPlaintextFields(RelayEnabled: false, Hostname: "secret.internal", Port: 22)), ])); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadContractAsync(); 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.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, Guid.CreateVersion7(), SyncOperation.Upsert, null, Payload([1, 2, 3]), new SyncPlaintextFields(RelayEnabled: true)), ])); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadContractAsync(); 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.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Upsert, null, Payload([1, 2, 3]), new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)), ])); await client.PostContractAsync(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.PostContractAsync(PushUrl(vaultId), create); await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete, ExpectedVersion: 1, Payload: null, PlaintextFields: null), ])); var pull = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); var body = await pull.Content.ReadContractAsync(); 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.PostContractAsync(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.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.Credential, Guid.CreateVersion7(), SyncOperation.Upsert, null, Payload([1]), null), ])); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadContractAsync(); body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } // Just-in-time provisioning is covered by IdentityEndpointTests, against /me — the endpoint a // client actually calls first, and the only one reachable before enrollment. // ---- SSH keys ---- [Fact] public async Task AnSshKey_RoundTripsWithNoPlaintextFields() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var keyId = Guid.CreateVersion7(); var pushed = await client.PostContractAsync( PushUrl(vaultId), new SyncPushRequest([KeyOperation(keyId, expectedVersion: null, envelope: [9, 8, 7])])); var results = await pushed.Content.ReadContractAsync(); results.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied); var pulled = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, [SyncEntityType.SshKey])); var page = await pulled.Content.ReadContractAsync(); var change = page.Changes.ShouldHaveSingleItem(); change.EntityType.ShouldBe(SyncEntityType.SshKey); change.EntityId.ShouldBe(keyId); change.Payload.ShouldNotBeNull().Envelope.ShouldBe(new byte[] { 9, 8, 7 }); // The point of the type. A key has no relay, so it has no plaintext columns at all — and null // rather than an all-defaults instance, which would still put "relayEnabled": false on the wire and // invite a reader to think the setting exists and is off. change.PlaintextFields.ShouldBeNull(); } [Fact] public async Task AnSshKeyCarryingARelayTarget_IsRefused() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var operation = KeyOperation(Guid.CreateVersion7(), null, [1]) with { PlaintextFields = new SyncPlaintextFields(RelayEnabled: true, Hostname: "db.internal", Port: 22) }; var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation])); var result = (await pushed.Content.ReadContractAsync()).Results.ShouldHaveSingleItem(); result.Status.ShouldBe(SyncOperationStatus.Invalid); result.Detail.ShouldContain("no relay target"); } /// /// The path most likely to break: a page of changes mixing item types has to load each type's rows /// separately and then put them back in the log's order, because a client's cursor cannot resume from a /// sequence that was regrouped. /// [Fact] public async Task APullMixingHostsAndKeys_ReturnsBothInLogOrder() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var hostId = Guid.CreateVersion7(); var keyId = Guid.CreateVersion7(); var pushed = await client.PostContractAsync( PushUrl(vaultId), new SyncPushRequest( [ NewOperation(hostId, expectedVersion: null, envelope: [1, 1]), KeyOperation(keyId, expectedVersion: null, envelope: [2, 2]), ])); (await pushed.Content.ReadContractAsync()).Results .ShouldAllBe(result => result.Status == SyncOperationStatus.Applied); var pulled = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); var page = await pulled.Content.ReadContractAsync(); page.Changes.Count.ShouldBe(2); page.Changes.Select(change => change.ChangeSequence) .ShouldBeInOrder(Shouldly.SortDirection.Ascending); var host = page.Changes.Single(change => change.EntityId == hostId); var key = page.Changes.Single(change => change.EntityId == keyId); host.EntityType.ShouldBe(SyncEntityType.Host); host.Payload.ShouldNotBeNull().Envelope.ShouldBe(new byte[] { 1, 1 }); host.PlaintextFields.ShouldNotBeNull(); key.EntityType.ShouldBe(SyncEntityType.SshKey); key.Payload.ShouldNotBeNull().Envelope.ShouldBe(new byte[] { 2, 2 }); key.PlaintextFields.ShouldBeNull(); } [Fact] public async Task DeletingAnSshKey_TombstonesItWithoutAPayload() { var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var keyId = Guid.CreateVersion7(); await client.PostContractAsync( PushUrl(vaultId), new SyncPushRequest([KeyOperation(keyId, null, [5])])); var deleted = await client.PostContractAsync( PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), SyncEntityType.SshKey, keyId, SyncOperation.Delete, ExpectedVersion: 1, Payload: null, PlaintextFields: null), ])); (await deleted.Content.ReadContractAsync()).Results .ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied); var pulled = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, [SyncEntityType.SshKey])); var page = await pulled.Content.ReadContractAsync(); var last = page.Changes[^1]; last.Operation.ShouldBe(SyncOperation.Delete); last.Payload.ShouldBeNull("a tombstone must not ship the key material it replaced"); } // ---- 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}"; /// /// A structurally valid payload. The bytes are meaningless on purpose: the server cannot read /// any of them, and a test that pretended otherwise would be testing the wrong thing. /// private static EncryptedPayload Payload(byte[] envelope) => new(envelope, WrappedDataKey: [0xD, 0xE], DataKeyId: Guid.CreateVersion7(), 1, 1); private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) => new( Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Upsert, expectedVersion, Payload(envelope), new SyncPlaintextFields()); /// /// PlaintextFields: null rather than an empty instance, which is what a real client sends for a /// type with no plaintext columns — and what the server must accept without inventing defaults. /// private static SyncPushOperation KeyOperation(Guid entityId, int? expectedVersion, byte[] envelope) => new( Guid.CreateVersion7(), SyncEntityType.SshKey, entityId, SyncOperation.Upsert, expectedVersion, Payload(envelope), PlaintextFields: null); 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 = NewUser(subject); 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.UserKeys.Add(Seed.CurrentKey(user.Id, Now)); database.Vaults.Add(vault); await database.SaveChangesAsync(); return (subject, vault.Id); } /// /// An enrolled user with no vault of their own: the realistic intruder. /// /// /// The denial tests use one of these rather than an unenrolled caller. An unenrolled caller is /// stopped by the enrolled policy before the vault check runs at all, which would leave the /// authorization tests passing without ever exercising the thing they exist to prove. /// private async Task SeedEnrolledUserAsync() { var subject = NewSubject(); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var user = NewUser(subject); database.Users.Add(user); database.UserKeys.Add(Seed.CurrentKey(user.Id, Now)); await database.SaveChangesAsync(); return subject; } private UserAccount NewUser(string subject) => new() { Id = Guid.CreateVersion7(), Issuer = fixture.IdentityProvider.Authority, Subject = subject, Status = UserStatus.Active, CreatedAtUtc = Now, UpdatedAtUtc = Now, }; private async Task SeedTeamVaultAsync() { await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var owner = NewUser(NewSubject()); 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; } }