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;

/// <summary>
/// End-to-end sync behaviour over HTTP, and the authorization denials.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<DodoDbContext>();
        (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<DodoDbContext>();

        (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<SyncPushResponse>();
        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<SyncPullResponse>();
        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<SyncPullResponse>();
        first.ShouldNotBeNull();

        // Nothing new since that cursor.
        var empty = await (await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(first.NextCursor, null, null)))
            .Content.ReadContractAsync<SyncPullResponse>();
        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<SyncPullResponse>();
        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<SyncPullResponse>())!.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<SyncPushResponse>();
        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<DodoDbContext>();
        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<SyncPushResponse>();
        body.ShouldNotBeNull();
        body.Results[0].Status.ShouldBe(SyncOperationStatus.Duplicate);

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        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<SyncPushResponse>();
        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<DodoDbContext>();
        (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<SyncPushResponse>();
        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<SyncPushResponse>();
        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<DodoDbContext>();
        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<SyncPullResponse>();
        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.
        //
        // The type is taken from the server's own registry rather than named, and that is not fussiness. This
        // test used to name Credential, and implementing credentials turned it into a test asserting the
        // opposite of the truth — it failed loudly, but a differently-shaped test would have gone quiet
        // instead. Asking the registry what is still missing keeps it aimed at the branch it was written for.
        var unsupported = Enum.GetValues<SyncEntityType>()
            .Where(type => type != SyncEntityType.Unspecified)
            .FirstOrDefault(type => Features.Sync.ItemKinds.For(type) is null);

        Assert.SkipWhen(
            unsupported == SyncEntityType.Unspecified,
            "Every entity type in the contract is implemented, so this branch is no longer reachable.");

        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
        [
            new SyncPushOperation(
                Guid.CreateVersion7(),
                unsupported,
                Guid.CreateVersion7(),
                SyncOperation.Upsert,
                null,
                Payload([1]),
                null),
        ]));

        response.EnsureSuccessStatusCode();

        var body = await response.Content.ReadContractAsync<SyncPushResponse>();
        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<SyncPushResponse>();
        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<SyncPullResponse>();
        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<SyncPushResponse>())!.Results.ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("no relay target");
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [Fact]
    public async Task ACredential_RoundTripsAsCiphertextWithNoPlaintextAtAll()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var credentialId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest(
                [CredentialOperation(credentialId, expectedVersion: null, envelope: [7, 7, 7])]));

        var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
        results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);

        var pulled = await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(null, null, [SyncEntityType.Credential]));

        var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
        var change = page!.Changes.ShouldHaveSingleItem();

        change.EntityType.ShouldBe(SyncEntityType.Credential);
        change.EntityId.ShouldBe(credentialId);
        change.Payload.ShouldNotBeNull().Envelope.ShouldBe([7, 7, 7]);

        change.PlaintextFields.ShouldBeNull(
            "a credential has no plaintext columns, so a pull has nothing to hydrate");
    }

    [Fact]
    public async Task ACredentialCarryingPlaintextFields_IsRejected()
    {
        // Refused rather than dropped. A client that thinks it is storing something and is not will be
        // surprised later, and for this type "something" would be a detail about a password.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = new SyncPushOperation(
            Guid.CreateVersion7(),
            SyncEntityType.Credential,
            Guid.CreateVersion7(),
            SyncOperation.Upsert,
            null,
            Payload([1, 2, 3]),
            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<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("no relay target");
    }

    [Fact]
    public async Task ACredentialWithAFingerprint_IsRejected()
    {
        // The one plaintext field a key may carry, refused here. A password has no public half, so a client
        // sending one is confused about what it is storing.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = new SyncPushOperation(
            Guid.CreateVersion7(),
            SyncEntityType.Credential,
            Guid.CreateVersion7(),
            SyncOperation.Upsert,
            null,
            Payload([1, 2, 3]),
            new SyncPlaintextFields(PublicKeyFingerprint: "SHA256:whatever"));

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("no public key");
    }

    [Fact]
    public async Task AKnownHostKey_RoundTripsAsCiphertextWithNoPlaintextAtAll()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var pinId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest(
                [KnownHostOperation(pinId, expectedVersion: null, envelope: [4, 2])]));

        var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
        results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);

        var pulled = await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(null, null, [SyncEntityType.KnownHostKey]));

        var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
        var change = page!.Changes.ShouldHaveSingleItem();

        change.EntityType.ShouldBe(SyncEntityType.KnownHostKey);
        change.EntityId.ShouldBe(pinId);
        change.Payload.ShouldNotBeNull().Envelope.ShouldBe([4, 2]);

        change.PlaintextFields.ShouldBeNull(
            "which endpoints a user has approved is not something this server keeps");
    }

    [Fact]
    public async Task AKnownHostKeyCarryingItsAddressInTheClear_IsRejected()
    {
        // The refusal that matters most of the four types, because this is the one item that genuinely holds
        // an address: a client that put it in the relay columns would be handing the operator a list of the
        // endpoints every user connects to, and it would look like an ordinary field while doing it.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = KnownHostOperation(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<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
    }

    [Fact]
    public async Task AKnownHostKeyWithAFingerprintColumn_IsRejected()
    {
        // A pin is nothing but a fingerprint, so this is the field a client would most plausibly think it
        // should send. The column exists for SSH keys, this client leaves even that one null, and a
        // fingerprint here would identify the server rather than the user's own key.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = KnownHostOperation(Guid.CreateVersion7(), null, [1])
            with
        { PlaintextFields = new SyncPlaintextFields(PublicKeyFingerprint: "SHA256:whatever") };

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
    }

    /// <remarks>
    /// Both log kinds at once, because they are the same shape and the property worth pinning is shared: an
    /// entry goes up as ciphertext and comes back with no plaintext field of any sort. A log is the one thing
    /// here whose <em>timestamps</em> would be genuinely useful to the server and are exactly what it must not
    /// have — a <c>started_at</c> column is a record of when each user works.
    /// </remarks>
    [Theory]
    [InlineData(SyncEntityType.ConnectionLogEntry)]
    [InlineData(SyncEntityType.ActivityLogEntry)]
    public async Task ALogEntry_RoundTripsAsCiphertextWithNoPlaintextAtAll(SyncEntityType type)
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var entryId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest([LogOperation(type, entryId, expectedVersion: null, envelope: [1, 1])]));

        var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
        results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);

        var pulled = await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(null, null, [type]));

        var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
        var change = page!.Changes.ShouldHaveSingleItem();

        change.EntityType.ShouldBe(type);
        change.EntityId.ShouldBe(entryId);
        change.Payload.ShouldNotBeNull().Envelope.ShouldBe([1, 1]);

        change.PlaintextFields.ShouldBeNull("when a user connects is not something this server keeps");
    }

    /// <remarks>
    /// <c>Kind</c> is the field a client would most plausibly fill in on an activity entry — it names which
    /// sort of item the entry is about, and it is right there in the contract. A column recording that
    /// somebody created four SSH keys last Tuesday describes the keychain out of facts that each look
    /// harmless, so it is refused rather than dropped.
    /// </remarks>
    [Theory]
    [InlineData(SyncEntityType.ConnectionLogEntry)]
    [InlineData(SyncEntityType.ActivityLogEntry)]
    public async Task ALogEntryCarryingAnythingInTheClear_IsRejected(SyncEntityType type)
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = LogOperation(type, Guid.CreateVersion7(), null, [1])
            with
        { PlaintextFields = new SyncPlaintextFields(Kind: 3, RelatedId: Guid.CreateVersion7()) };

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
    }

    /// <remarks>
    /// The endpoint is the field a client might reach for, and it is the one that must not go: for everybody
    /// self-hosting it is an address on their own network, which is exactly what the host table only holds in
    /// the clear when the relay cannot work without it.
    /// </remarks>
    [Fact]
    public async Task ABucket_RoundTripsAsCiphertextWithNoPlaintextAtAll()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var bucketId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest(
                [BucketOperation(bucketId, expectedVersion: null, envelope: [9, 9])]));

        var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
        results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);

        var pulled = await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(null, null, [SyncEntityType.ObjectStore]));

        var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
        var change = page!.Changes.ShouldHaveSingleItem();

        change.EntityType.ShouldBe(SyncEntityType.ObjectStore);
        change.EntityId.ShouldBe(bucketId);
        change.Payload.ShouldNotBeNull().Envelope.ShouldBe([9, 9]);

        change.PlaintextFields.ShouldBeNull("where a user keeps their data is not something this server keeps");
    }

    [Fact]
    public async Task ABucketCarryingItsEndpointInTheClear_IsRejected()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = BucketOperation(Guid.CreateVersion7(), null, [1])
            with
        { PlaintextFields = new SyncPlaintextFields(RelayEnabled: true, Hostname: "minio.internal", Port: 9000) };

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
    }

    [Fact]
    public async Task AHostGroup_RoundTripsAsCiphertextWithNoPlaintextAtAll()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var groupId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest(
                [HostGroupOperation(groupId, expectedVersion: null, envelope: [7, 7])]));

        var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
        results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);

        var pulled = await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(null, null, [SyncEntityType.HostGroup]));

        var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
        var change = page!.Changes.ShouldHaveSingleItem();

        change.EntityType.ShouldBe(SyncEntityType.HostGroup);
        change.EntityId.ShouldBe(groupId);
        change.Payload.ShouldNotBeNull().Envelope.ShouldBe([7, 7]);

        change.PlaintextFields.ShouldBeNull(
            "how a user files their machines is not something this server keeps");
    }

    /// <remarks>
    /// <para>
    /// <b>The regression guard for the decision that group membership stays encrypted.</b>
    /// <c>SyncPlaintextFields.GroupId</c> cannot be removed — the wire contract is frozen — and the server
    /// used to copy it into a column. Nothing ever sent one, so the column was dropped rather than migrated;
    /// this is what stops a later client filling the field in and finding it silently accepted.
    /// </para>
    /// <para>
    /// Run for a relay host as well as an ordinary one, because the check has to sit ahead of the relay branch
    /// to apply to both — and the relay branch returns early on its happy path, so a check written after it
    /// would pass this test in the <see langword="false"/> case and let the value through in the other.
    /// </para>
    /// </remarks>
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task AHostCarryingItsGroupInTheClear_IsRejected(bool relayEnabled)
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var fields = relayEnabled
            ? new SyncPlaintextFields(
                RelayEnabled: true,
                Hostname: "db.internal",
                Port: 22,
                GroupId: Guid.CreateVersion7())
            : new SyncPlaintextFields(GroupId: Guid.CreateVersion7());

        var operation = NewOperation(Guid.CreateVersion7(), null, [1]) with { PlaintextFields = fields };

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("inside its encrypted payload");
    }

    [Fact]
    public async Task AHostGroupCarryingAPlaintextParent_IsRejected()
    {
        // Groups nest, and this refusal survived the change that made them nest — with a different reason.
        // It used to mean "there is no such thing as a parent". It now means "the parent is not the server's
        // to hold": the pointer lives inside the envelope, and a plaintext column here would hand the
        // operator the shape of every user's estate, which is the disclosure ADR 0004 refuses everywhere
        // except the one address the relay cannot dial without.
        //
        // The consequence the old reason worried about is real and is handled elsewhere: the server cannot
        // see a cycle assembled from two offline re-parents, so every client walk carries a visited set.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = HostGroupOperation(Guid.CreateVersion7(), null, [1])
            with
        { PlaintextFields = new SyncPlaintextFields(ParentId: Guid.CreateVersion7()) };

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
    }

    [Fact]
    public async Task ATag_RoundTripsAsCiphertextWithNoPlaintextAtAll()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var tagId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest([TagOperation(tagId, expectedVersion: null, envelope: [9, 9])]));

        var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
        results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);

        var pulled = await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(null, null, [SyncEntityType.Tag]));

        var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
        var change = page!.Changes.ShouldHaveSingleItem();

        change.EntityType.ShouldBe(SyncEntityType.Tag);
        change.EntityId.ShouldBe(tagId);
        change.Payload.ShouldNotBeNull().Envelope.ShouldBe([9, 9]);

        change.PlaintextFields.ShouldBeNull(
            "a tag name says what a machine is, and one tag spans twenty of them — a column here would be a "
            + "labelled map of the estate, for a sort this server never performs");
    }

    [Fact]
    public async Task ATagCarryingTheHostItIsOn_IsRejected()
    {
        // RelatedId is where a join row would put the host, and SyncEntityType.HostTag reserves the slot for
        // exactly that join. This design keeps membership in the host's payload instead, so a client
        // reaching for the field would be writing the host-to-tag graph into the clear one row at a time.
        // SyncPlaintextFields is frozen, so refusing it here is the only place the decision can be enforced.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = TagOperation(Guid.CreateVersion7(), null, [9])
            with
        { PlaintextFields = new SyncPlaintextFields(RelatedId: Guid.CreateVersion7()) };

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("inside theirs");
    }

    [Fact]
    public async Task ASnippet_RoundTripsAsCiphertextWithNoPlaintextAtAll()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var snippetId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest(
                [SnippetOperation(snippetId, expectedVersion: null, envelope: [8, 8])]));

        var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
        results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);

        var pulled = await client.PostContractAsync(
            PullUrl(vaultId),
            new SyncPullRequest(null, null, [SyncEntityType.Snippet]));

        var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
        var change = page!.Changes.ShouldHaveSingleItem();

        change.EntityType.ShouldBe(SyncEntityType.Snippet);
        change.EntityId.ShouldBe(snippetId);
        change.Payload.ShouldNotBeNull().Envelope.ShouldBe([8, 8]);

        change.PlaintextFields.ShouldBeNull("what a user runs is not something this server keeps");
    }

    [Fact]
    public async Task ASnippetCarryingTheHostItIsScopedTo_IsRejected()
    {
        // Host scoping is not in the first version of snippets, and RelatedId is where a client would put it.
        // Refused rather than dropped: the field would tell the operator which commands belong to which
        // machine, which is the clustering the payload exists to keep out of their hands.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var operation = SnippetOperation(Guid.CreateVersion7(), null, [1])
            with
        { PlaintextFields = new SyncPlaintextFields(RelatedId: Guid.CreateVersion7()) };

        var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));

        var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
            .ShouldHaveSingleItem();

        result.Status.ShouldBe(SyncOperationStatus.Invalid);
        result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
    }

    [Fact]
    public async Task ThreeItemTypesWithOneId_AreThreeSeparateItems()
    {
        // The tables are separate, so one id may name a host, a key and a credential at once. Not something a
        // client would do — ids are UUIDv7 — but if the write path ever confused two types, this is the test
        // that says so rather than a mystery about a missing item.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        var sharedId = Guid.CreateVersion7();

        var pushed = await client.PostContractAsync(
            PushUrl(vaultId),
            new SyncPushRequest(
            [
                NewOperation(sharedId, expectedVersion: null, envelope: [1, 1]),
                KeyOperation(sharedId, expectedVersion: null, envelope: [2, 2]),
                CredentialOperation(sharedId, expectedVersion: null, envelope: [3, 3]),
            ]));

        (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.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<SyncPullResponse>();

        page!.Changes.Count.ShouldBe(3);
        page.Changes.ShouldAllBe(change => change.EntityId == sharedId);

        page.Changes.Select(change => change.EntityType).Order().ShouldBe(
            [SyncEntityType.Host, SyncEntityType.Credential, SyncEntityType.SshKey]);
    }

    [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<SyncPushResponse>())!.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<SyncPullResponse>();

        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<SyncPushResponse>())!.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<SyncPullResponse>();
        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}";

    /// <summary>
    /// 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.
    /// </summary>
    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());

    /// <remarks>
    /// <c>PlaintextFields: null</c> 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.
    /// </remarks>
    /// <remarks>
    /// Like <see cref="KeyOperation"/> with even less: a credential has no plaintext column at all, not even
    /// the fingerprint a key may carry, so this is the narrowest an operation gets.
    /// </remarks>
    private static SyncPushOperation CredentialOperation(
        Guid entityId,
        int? expectedVersion,
        byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.Credential,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            Payload(envelope),
            PlaintextFields: null);

    private static SyncPushOperation KeyOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.SshKey,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            Payload(envelope),
            PlaintextFields: null);

    /// <remarks>
    /// As narrow as <see cref="CredentialOperation"/>, and worth stating why for a type that is nothing but an
    /// address and a fingerprint: both stay inside the envelope, so there is no field here either.
    /// </remarks>
    private static SyncPushOperation KnownHostOperation(
        Guid entityId,
        int? expectedVersion,
        byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.KnownHostKey,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            Payload(envelope),
            PlaintextFields: null);

    /// <remarks>
    /// Narrow for the same reason as the two above, and the fields it most conspicuously does not carry are
    /// the two named after the type: a group's name is inside the envelope, and so are its membership and
    /// its parent.
    /// </remarks>
    private static SyncPushOperation HostGroupOperation(
        Guid entityId,
        int? expectedVersion,
        byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.HostGroup,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            Payload(envelope),
            PlaintextFields: null);

    private static SyncPushOperation TagOperation(
        Guid entityId,
        int? expectedVersion,
        byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.Tag,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            Payload(envelope),
            PlaintextFields: null);

    private static SyncPushOperation SnippetOperation(
        Guid entityId,
        int? expectedVersion,
        byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.Snippet,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            Payload(envelope),
            PlaintextFields: null);

    /// <remarks>
    /// One helper for both log kinds, because they differ by exactly one enum value — which is the point:
    /// anything that were true of one and not the other would be a difference nothing else in the design
    /// asks for.
    /// </remarks>
    private static SyncPushOperation LogOperation(
        SyncEntityType type,
        Guid entityId,
        int? expectedVersion,
        byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            type,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            Payload(envelope),
            PlaintextFields: null);

    private static SyncPushOperation BucketOperation(
        Guid entityId,
        int? expectedVersion,
        byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.ObjectStore,
            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<DodoDbContext>();

        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);
    }

    /// <summary>
    /// An enrolled user with no vault of their own: the realistic intruder.
    /// </summary>
    /// <remarks>
    /// 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.
    /// </remarks>
    private async Task<string> SeedEnrolledUserAsync()
    {
        var subject = NewSubject();

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        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<Guid> SeedTeamVaultAsync()
    {
        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        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;
    }
}
