Files
DodoSSH/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs
T
jaap-jan e24012b039 Sync credentials as a vault item type, and bind one to a host
Closes the largest remaining M1 gap in the data layer: a username and password
can live in the vault, sync between machines, and be named by a host as how it
authenticates. What is not here is the interface for creating one — see the end
of this message.

The third item type, and the first one that cost almost nothing to add. Server:
a VaultCredential row, an EF configuration, a migration, and a CredentialKind.
Client: a secret, a codec, a merge, a cipher, a kind, a repository facade and a
session property. No new reconciliation logic, no change to the sync engine, no
client cache migration. That was the whole point of the item-kind seam, and this
is the evidence it holds.

The narrowest type of the three on plaintext, and not for symmetry. A host has a
deliberate concession — the relay needs an address it can resolve. A key has a
fingerprint, public by nature, which this client still declines to send. A
password has no part that is safe to expose: not its length, not a hash, not a
hint. So CredentialKind refuses every plaintext field there is, hydrates none,
and the table has no column to put one in.

HostSecret.CredentialId is the password counterpart of SshKeyId, and the two are
mutually exclusive. SSH itself would happily try a key and fall back to a
password, but a host naming both leaves "how does this authenticate?" without a
single answer — the interface, the connect path and the user would each be free
to guess differently. TryValidate refuses it. One consequence was not
anticipated: "a full host" stops being a coherent idea, which is what broke
AFullHost_RoundTrips and is now written into that test.

The schema version became a ladder rather than a maximum: credential-bound is 3,
key-bound is 2, neither is still 1. Adding credentials therefore does not drag
every key-bound host in every vault onto a version that clients understanding
keys perfectly well would refuse to edit. A test pins exactly that, because it is
the property the whole content-dependent-version rule exists to provide, and the
obvious implementation would quietly lose it.

Two tests had become false and said so:

- Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch used Credential as its
  example of a type this server does not implement. It now asks the server's own
  registry what is still missing, so it cannot go stale again, and skips with a
  reason if that set ever empties.
- ThePullFilterNamesEveryTypeThisBuildSynchronises pinned the exact list, which
  is what it is for.

Also fixes ten nullable warnings — eight in SyncEndpointTests, two in a test file
added earlier today. Neither set was introduced here; both were invisible until
an unrelated change forced their project to recompile, which means the
zero-warning claims made earlier in this work only ever covered what happened to
be rebuilt.

777 tests green. Zero warnings, dotnet format clean.

Not done, and deliberately: the credential interface. The vault column is 340
pixels wide and already holds two lists and two editors, kept from clipping its
own buttons at the window's minimum height only by the one-editor-at-a-time rule
added earlier today. A third list and a third editor would recreate that defect
rather than avoid it, so the column needs a shape decision first. Credentials
sync; they cannot yet be created in the interface.
2026-07-29 21:09:08 +02:00

460 lines
16 KiB
C#

using System.Globalization;
using DodoSSH.Client.Api;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// An in-memory vault server with the real sync semantics.
/// </summary>
/// <remarks>
/// <para>
/// A faithful reimplementation of <c>DodoSSH.Api.Features.Sync.SyncService</c>'s decision table: the
/// version check, the tombstone-beats-late-upsert rule, idempotent deletes, operation receipts, the
/// change log, cursors that are opaque to the client, the pull filter, and the per-type rules about which
/// plaintext columns an item may carry. It is not a stub that returns canned answers — if it were, none of
/// the conflict tests would mean anything, because the interesting behaviour is exactly the server's
/// refusal to apply a stale write.
/// </para>
/// <para>
/// The duplication against the real service is deliberate and is the point of the exercise: two
/// independent expressions of the same rules, and <c>SyncEndpointTests</c> checks the other one against
/// real Postgres. A shared implementation would let a misreading of the protocol pass on both sides.
/// </para>
/// <para>
/// Rows are keyed on the entity type as well as the id, as the server's separate tables are and as the
/// client's cache is. Keying on the id alone would work for every test that uses one item type and would
/// silently make a host and a key with the same id the same row.
/// </para>
/// </remarks>
internal sealed class FakeVaultServer : ISyncApi
{
/// <summary>The item types this fake knows, mirroring the server's own registry.</summary>
private static readonly SyncEntityType[] Supported =
[SyncEntityType.Host, SyncEntityType.SshKey, SyncEntityType.Credential];
private readonly Dictionary<(SyncEntityType Type, Guid EntityId), Row> rows = [];
private readonly List<LogEntry> log = [];
private readonly Dictionary<Guid, Receipt> receipts = [];
internal FakeVaultServer(Guid vaultId, uint keyGeneration = 1)
{
VaultId = vaultId;
KeyGeneration = keyGeneration;
}
internal Guid VaultId { get; }
internal uint KeyGeneration { get; set; }
/// <summary>The server's clock, so a test can create skew deliberately.</summary>
internal DateTimeOffset Now { get; set; } = DateTimeOffset.FromUnixTimeSeconds(1_750_000_000);
/// <summary>Pull pages are capped here, as the real server clamps a client's requested limit.</summary>
internal int MaxPullLimit { get; set; } = 500;
/// <summary>Forces the next push to answer <see cref="SyncOperationStatus.Forbidden"/>.</summary>
internal bool DenyWrites { get; set; }
/// <summary>Pushes received, so a test can prove a retry did or did not happen.</summary>
internal int PushCount { get; private set; }
/// <summary>The entity-type filter of the last pull, so a test can assert what was asked for.</summary>
internal IReadOnlyList<SyncEntityType>? LastPullTypes { get; private set; }
/// <summary>
/// Runs just before a push is applied, so a test can land another client's write in the window
/// between one client's pull and its push. That window is the whole subject of the cursor-gap test.
/// </summary>
internal Action? OnPush { get; set; }
internal int RowCount => rows.Count(entry => !entry.Value.IsDeleted);
/// <inheritdoc />
public Task<SyncPullResponse> SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var after = DecodeCursor(request.Cursor);
var limit = Math.Clamp(request.Limit ?? MaxPullLimit, 1, MaxPullLimit);
LastPullTypes = request.EntityTypes;
// Empty or absent means every type, as the contract says.
var wanted = request.EntityTypes is { Count: > 0 } types ? types : null;
var page = log
.Where(entry => entry.Sequence > after)
.Where(entry => wanted is null || wanted.Contains(entry.EntityType))
.Take(limit + 1)
.ToList();
var hasMore = page.Count > limit;
if (hasMore)
{
page.RemoveAt(page.Count - 1);
}
// When nothing came back the cursor must not move, or a write landing between this read and the
// next would be skipped for ever.
var next = page.Count > 0 ? page[^1].Sequence : after;
return Task.FromResult(new SyncPullResponse(
[.. page.Select(entry => Hydrate(entry))],
EncodeCursor(next),
hasMore,
Now,
KeyGeneration));
}
/// <inheritdoc />
public Task<SyncPushResponse> SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken)
{
PushCount++;
var interleaved = OnPush;
OnPush = null;
interleaved?.Invoke();
var results = new List<SyncPushResult>(request.Operations.Count);
foreach (var operation in request.Operations)
{
results.Add(Apply(operation));
}
return Task.FromResult(new SyncPushResponse(results, EncodeCursor(Head)));
}
/// <summary>Applies a change as if another client had made it.</summary>
internal int ExternalUpsert(
Guid entityId,
EncryptedPayload payload,
SyncPlaintextFields? fields,
SyncEntityType entityType = SyncEntityType.Host)
{
var result = Apply(new SyncPushOperation(
Guid.CreateVersion7(),
entityType,
entityId,
SyncOperation.Upsert,
rows.TryGetValue((entityType, entityId), out var existing) && !existing.IsDeleted
? existing.Version
: null,
payload,
fields));
if (result.Status != SyncOperationStatus.Applied)
{
throw new InvalidOperationException(
$"The external write was not applied: {result.Status} — {result.Detail}.");
}
return result.Version!.Value;
}
/// <summary>Deletes as if another client had done it.</summary>
internal void ExternalDelete(Guid entityId, SyncEntityType entityType = SyncEntityType.Host)
{
var existing = rows[(entityType, entityId)];
var result = Apply(new SyncPushOperation(
Guid.CreateVersion7(),
entityType,
entityId,
SyncOperation.Delete,
existing.Version,
null,
null));
if (result.Status != SyncOperationStatus.Applied)
{
throw new InvalidOperationException($"The external delete was not applied: {result.Status}.");
}
}
internal Row? Find(Guid entityId, SyncEntityType entityType = SyncEntityType.Host) =>
rows.TryGetValue((entityType, entityId), out var row) ? row : null;
private long Head => log.Count == 0 ? 0 : log[^1].Sequence;
// ---- The decision table ----
private SyncPushResult Apply(SyncPushOperation operation)
{
if (!Supported.Contains(operation.EntityType))
{
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
}
if (receipts.TryGetValue(operation.OperationId, out var receipt))
{
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Duplicate,
receipt.Version,
receipt.Sequence,
null,
null);
}
if (DenyWrites)
{
return new SyncPushResult(
operation.OperationId, SyncOperationStatus.Forbidden, null, null, null, null);
}
rows.TryGetValue((operation.EntityType, operation.EntityId), out var existing);
return operation.Operation == SyncOperation.Delete
? ApplyDelete(operation, existing)
: ApplyUpsert(operation, existing);
}
private SyncPushResult ApplyUpsert(SyncPushOperation operation, Row? existing)
{
if (operation.Payload is null)
{
return Invalid(operation, "An upsert requires a payload.");
}
if (operation.Payload.WrappedDataKey.Length == 0 || operation.Payload.DataKeyId == Guid.Empty)
{
return Invalid(operation, "A payload requires its data key.");
}
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
if (!ValidateFields(operation.EntityType, fields, out var fieldError))
{
return Invalid(operation, fieldError);
}
if (existing is null || existing.IsDeleted)
{
return Create(operation, existing, fields);
}
if (operation.ExpectedVersion != existing.Version)
{
return Conflict(operation, existing);
}
var updated = existing with
{
Version = existing.Version + 1,
Payload = operation.Payload,
Fields = fields,
IsDeleted = false,
};
return Commit(operation, updated, SyncOperation.Upsert);
}
/// <summary>The per-type rules about which plaintext columns an item may carry.</summary>
/// <remarks>
/// A key's are stricter than a host's rather than merely different, and that asymmetry is the point:
/// the relay concession belongs to hosts alone, so a key arriving with an address is a client bug and
/// is refused with a reason instead of being quietly dropped.
/// </remarks>
private static bool ValidateFields(
SyncEntityType entityType,
SyncPlaintextFields fields,
out string error)
{
error = string.Empty;
if (entityType == SyncEntityType.SshKey)
{
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "An SSH key has no relay target; relay fields may only be set on a host.";
return false;
}
return true;
}
if (entityType == SyncEntityType.Credential)
{
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A credential has no relay target; relay fields may only be set on a host.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A credential has no public key.";
return false;
}
return true;
}
if (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null))
{
error = "An address may only be supplied when relay is enabled.";
return false;
}
if (fields.RelayEnabled && (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null))
{
error = "Relay-enabled hosts require both a hostname and a port.";
return false;
}
return true;
}
private SyncPushResult Create(SyncPushOperation operation, Row? existing, SyncPlaintextFields fields)
{
// A tombstone beats a late upsert. The client is told so it can resurrect the item deliberately
// under a new id rather than silently undoing someone else's delete.
if (existing?.IsDeleted == true)
{
return Conflict(operation, existing);
}
if (operation.ExpectedVersion is not null)
{
// The client believes it is updating something that does not exist here.
return Conflict(operation, existing: null);
}
var created = new Row(
operation.EntityType, operation.EntityId, 1, 0, operation.Payload!, fields, false);
return Commit(operation, created, SyncOperation.Upsert);
}
private SyncPushResult ApplyDelete(SyncPushOperation operation, Row? existing)
{
if (existing is null)
{
return Invalid(operation, "Cannot delete an item that does not exist.");
}
if (existing.IsDeleted)
{
// Idempotent: a client retrying a delete it is unsure about should not have to tell these
// two situations apart.
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
existing.Version,
existing.ChangeSequence,
null,
null);
}
if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
{
return Conflict(operation, existing);
}
var tombstone = existing with
{
Version = existing.Version + 1,
IsDeleted = true,
// The address goes with the item, or the server stays able to resolve a host the user
// believes they deleted.
Fields = new SyncPlaintextFields(),
};
return Commit(operation, tombstone, SyncOperation.Delete);
}
private SyncPushResult Commit(SyncPushOperation operation, Row row, SyncOperation change)
{
var sequence = Head + 1;
log.Add(new LogEntry(sequence, row.EntityType, row.EntityId, change, row.Version, Now));
rows[(row.EntityType, row.EntityId)] = row with { ChangeSequence = sequence };
receipts[operation.OperationId] = new Receipt(row.Version, sequence);
return new SyncPushResult(
operation.OperationId, SyncOperationStatus.Applied, row.Version, sequence, null, null);
}
private SyncPushResult Conflict(SyncPushOperation operation, Row? existing) =>
new(
operation.OperationId,
SyncOperationStatus.Conflict,
existing?.Version,
existing?.ChangeSequence,
existing is null ? null : ToChange(existing),
null);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new(operation.OperationId, SyncOperationStatus.Invalid, null, null, null, detail);
private SyncChange Hydrate(LogEntry entry)
{
var row = rows[(entry.EntityType, entry.EntityId)];
return ToChange(row, entry.Sequence, entry.Revision, entry.OccurredAt);
}
private SyncChange ToChange(Row row, long? sequence = null, int? version = null, DateTimeOffset? at = null) =>
new(
row.EntityType,
row.EntityId,
row.IsDeleted ? SyncOperation.Delete : SyncOperation.Upsert,
version ?? row.Version,
sequence ?? row.ChangeSequence,
// A delete carries no payload: there is nothing left to decrypt, and shipping the pre-delete
// ciphertext would undermine the point of the tombstone.
row.IsDeleted ? null : row.Payload,
row.IsDeleted ? null : row.Fields,
at ?? Now);
// ---- Cursors ----
/// <remarks>
/// Prefixed and non-numeric so a client that tried to compute one would produce something this
/// rejects. The real server HMAC-tags them; the property that matters to the client is only that it
/// must round-trip what it is given.
/// </remarks>
private static string EncodeCursor(long sequence) =>
"fake-v1:" + sequence.ToString(CultureInfo.InvariantCulture);
private static long DecodeCursor(string? cursor)
{
if (string.IsNullOrEmpty(cursor))
{
return 0;
}
if (!cursor.StartsWith("fake-v1:", StringComparison.Ordinal)
|| !long.TryParse(cursor.AsSpan(8), CultureInfo.InvariantCulture, out var sequence))
{
throw new InvalidOperationException($"A client sent a cursor it should not have: '{cursor}'.");
}
return sequence;
}
internal sealed record Row(
SyncEntityType EntityType,
Guid EntityId,
int Version,
long ChangeSequence,
EncryptedPayload Payload,
SyncPlaintextFields Fields,
bool IsDeleted);
private sealed record LogEntry(
long Sequence,
SyncEntityType EntityType,
Guid EntityId,
SyncOperation Operation,
int Revision,
DateTimeOffset OccurredAt);
private sealed record Receipt(int Version, long Sequence);
}