Files
DodoSSH/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs
T
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00

736 lines
26 KiB
C#

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.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 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.PostAsJsonAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await response.Content.ReadFromJsonAsync<JsonProblem>();
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.PostAsJsonAsync(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.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(await SeedEnrolledUserAsync());
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(await SeedEnrolledUserAsync());
var batch = NewCreateBatch();
await intruder.PostAsJsonAsync(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.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(await SeedEnrolledUserAsync());
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<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.PostAsJsonAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null));
pull.EnsureSuccessStatusCode();
var pulled = await pull.Content.ReadFromJsonAsync<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.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
var first = await (await client.PostAsJsonAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null))).Content.ReadFromJsonAsync<SyncPullResponse>();
first.ShouldNotBeNull();
// Nothing new since that cursor.
var empty = await (await client.PostAsJsonAsync(
PullUrl(vaultId),
new SyncPullRequest(first.NextCursor, null, null)))
.Content.ReadFromJsonAsync<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.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
var second = await (await client.PostAsJsonAsync(
PullUrl(vaultId),
new SyncPullRequest(first.NextCursor, null, null)))
.Content.ReadFromJsonAsync<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.PostAsJsonAsync(
PullUrl(firstVault),
new SyncPullRequest(null, null, null));
var cursor = (await pull.Content.ReadFromJsonAsync<SyncPullResponse>())!.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<JsonProblem>();
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<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.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<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.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<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.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<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.PostAsJsonAsync(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.ReadFromJsonAsync<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.PostAsJsonAsync(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.ReadFromJsonAsync<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.PostAsJsonAsync(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.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<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.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<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.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,
Payload([1]),
null),
]));
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<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.
// ---- 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());
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;
}
}