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