Public Access
1781 lines
70 KiB
C#
1781 lines
70 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using DodoSSH.Api.Features.Sync;
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An item edited several times is pulled once, at the revision its ciphertext is sealed at.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The table holds only the newest payload, and its AAD binds the newest version. Hydrating each older
|
|
/// log entry with it paired revision 1 or 2 with a payload sealed at 3, which no client can open — and a
|
|
/// client that could not open it parked its own pending edit as undecryptable.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Pull_OfAnItemEditedSinceItsEarlierEntries_SendsOnlyItsCurrentRevision()
|
|
{
|
|
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: [2, 2, 2]),
|
|
]));
|
|
|
|
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
|
[
|
|
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
|
|
]));
|
|
|
|
var pulled = await (await client.PostContractAsync(
|
|
PullUrl(vaultId),
|
|
new SyncPullRequest(null, null, null))).Content.ReadContractAsync<SyncPullResponse>();
|
|
|
|
pulled.ShouldNotBeNull();
|
|
|
|
var change = pulled.Changes.ShouldHaveSingleItem();
|
|
change.Version.ShouldBe(3);
|
|
change.Payload!.Envelope.ShouldBe([3, 3, 3]);
|
|
|
|
// And the cursor is past all three entries, so the next pull does not offer the old ones again.
|
|
var next = await (await client.PostContractAsync(
|
|
PullUrl(vaultId),
|
|
new SyncPullRequest(pulled.NextCursor, null, null))).Content.ReadContractAsync<SyncPullResponse>();
|
|
|
|
next!.Changes.ShouldBeEmpty();
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
// ---- Tombstone collection (ADR 0003) ----
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A tombstone past its retention goes, with its change-log entries — and a client whose cursor is from
|
|
/// before it is refused, because it would otherwise never hear of the deletion. A client already past it
|
|
/// carries on, and a pull from nothing simply no longer offers the item.
|
|
/// </para>
|
|
/// <para>
|
|
/// The retention is crossed by moving the deletion back in time rather than by waiting it out.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnExpiredTombstone_IsCollected_AndACursorFromBeforeIt_IsRefused()
|
|
{
|
|
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);
|
|
|
|
var before = await PullAsync(client, vaultId, null);
|
|
|
|
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
|
[
|
|
new SyncPushOperation(
|
|
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
|
|
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
|
|
]));
|
|
|
|
var after = await PullAsync(client, vaultId, before.NextCursor);
|
|
|
|
await AgeTheDeletionAsync(entityId);
|
|
await CollectAsync();
|
|
|
|
var refused = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(before.NextCursor, null, null));
|
|
refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
(await refused.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.InvalidCursor);
|
|
|
|
(await PullAsync(client, vaultId, after.NextCursor)).Changes.ShouldBeEmpty("a cursor past the deletion is fine");
|
|
|
|
(await PullAsync(client, vaultId, null)).Changes.ShouldNotContain(change => change.EntityId == entityId);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half of the retention: a deletion younger than it stays, entries and all, so a machine that
|
|
/// has been away a week still hears of it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ATombstoneInsideItsRetention_IsKept()
|
|
{
|
|
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);
|
|
|
|
var before = await PullAsync(client, vaultId, null);
|
|
|
|
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
|
[
|
|
new SyncPushOperation(
|
|
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
|
|
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
|
|
]));
|
|
|
|
await CollectAsync();
|
|
|
|
var later = await PullAsync(client, vaultId, before.NextCursor);
|
|
|
|
later.Changes.ShouldHaveSingleItem().Operation.ShouldBe(SyncOperation.Delete);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An item edited many times kept an entry per edit for ever, and a client starting from nothing walked
|
|
/// every one of them. After a collection only the newest is left — and a client that held a cursor from
|
|
/// between the edits still gets the item, because what it had not seen yet is exactly that newest entry.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SupersededEntries_AreCompactedAway_WithoutCostingAnyClientTheItem()
|
|
{
|
|
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);
|
|
|
|
var between = await PullAsync(client, vaultId, null);
|
|
|
|
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
|
[
|
|
NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]),
|
|
]));
|
|
|
|
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
|
[
|
|
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
|
|
]));
|
|
|
|
await CollectAsync();
|
|
|
|
await using (var scope = fixture.CreateScope())
|
|
{
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
(await database.VaultChanges.CountAsync(c => c.EntityId == entityId, TestContext.Current.CancellationToken))
|
|
.ShouldBe(1);
|
|
}
|
|
|
|
(await PullAsync(client, vaultId, between.NextCursor)).Changes.ShouldHaveSingleItem().Version.ShouldBe(3);
|
|
(await PullAsync(client, vaultId, null)).Changes.Single(c => c.EntityId == entityId).Version.ShouldBe(3);
|
|
}
|
|
|
|
private static async Task<SyncPullResponse> PullAsync(HttpClient client, Guid vaultId, string? cursor)
|
|
{
|
|
var response = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(cursor, null, null));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
return (await response.Content.ReadContractAsync<SyncPullResponse>()).ShouldNotBeNull();
|
|
}
|
|
|
|
/// <summary>Moves a deletion back past the retention, which is quicker than waiting ninety days.</summary>
|
|
private async Task AgeTheDeletionAsync(Guid entityId)
|
|
{
|
|
await using var scope = fixture.CreateScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
|
|
var host = await database.Hosts.SingleAsync(h => h.Id == entityId, TestContext.Current.CancellationToken);
|
|
host.DeletedAtUtc = host.DeletedAtUtc!.Value.AddDays(-91);
|
|
|
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
private async Task CollectAsync()
|
|
{
|
|
await using var scope = fixture.CreateScope();
|
|
|
|
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>().RunOnceAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
// ---- 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 ASpelunkKey_RoundTripsAsCiphertextWithNoPlaintextAtAll()
|
|
{
|
|
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
|
var client = fixture.CreateClientFor(subject);
|
|
|
|
var keyId = Guid.CreateVersion7();
|
|
|
|
var pushed = await client.PostContractAsync(
|
|
PushUrl(vaultId),
|
|
new SyncPushRequest(
|
|
[SpelunkKeyOperation(keyId, 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.SpelunkKey]));
|
|
|
|
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
|
|
var change = page!.Changes.ShouldHaveSingleItem();
|
|
|
|
change.EntityType.ShouldBe(SyncEntityType.SpelunkKey);
|
|
change.EntityId.ShouldBe(keyId);
|
|
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([4, 2]);
|
|
|
|
change.PlaintextFields.ShouldBeNull("where somebody manages their servers from is not this server's to keep");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ASpelunkKeyCarryingItsAddressInTheClear_IsRejected()
|
|
{
|
|
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
|
var client = fixture.CreateClientFor(subject);
|
|
|
|
var operation = SpelunkKeyOperation(Guid.CreateVersion7(), null, [1])
|
|
with
|
|
{ PlaintextFields = new SyncPlaintextFields(RelayEnabled: false, Hostname: "api.spelunk.example.com", Port: 443) };
|
|
|
|
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 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 SyncPushOperation SpelunkKeyOperation(
|
|
Guid entityId,
|
|
int? expectedVersion,
|
|
byte[] envelope) =>
|
|
new(
|
|
Guid.CreateVersion7(),
|
|
SyncEntityType.SpelunkKey,
|
|
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;
|
|
}
|
|
}
|