Files
DodoSSH/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
T
jaap-jan c6fc19bbbd Sync the vault automatically instead of only on a button press
Three triggers: once when the vault opens, straight after any local change,
and every minute while it stays open. The Sync button stays, because someone
just handed a credential wants to know now rather than within the minute, but
nothing depends on it being pressed any more.

A background pass is deliberately not the button's code path. Routing it
through RunAsync would raise the busy flag every minute — disabling Connect
and Save for the duration — and repaint the status line over whatever the user
was reading. So it is quiet: the status changes only when a pass actually
moved an item or produced something needing attention, and a pass is skipped
outright while a command is running rather than queueing behind it. Both
guards are covered; removing either fails a test.

A shared semaphore serialises every pass, taken with a zero timeout rather
than awaited — a pass arriving while another runs has nothing to add by
waiting, and queueing them would turn a slow server into a backlog of
identical work.

Failures are swallowed, which is right in exactly this one place: a laptop
closed all afternoon would otherwise replace the status line with a socket
error once a minute. It is quiet rather than hidden — the account bar already
shows when there is no connection, and pressing Sync reports the real reason.
What earns that is the outbox: a test proves a change left queued by a failed
pass is still sent by the next sync, so quiet never means lost.

Two existing tests asserted the opposite behaviour — that a save queued and
pushed nothing until Sync was pressed — and were rewritten rather than
deleted; the local-first guarantee they were really protecting is that the
list updates with no server, which the offline test still covers.

Two things the tests caught in my own work. ReloadAsync had to be split out
of LoadAsync because rebuilding the list repainted the status line
unconditionally, which made "the background pass is quiet" false on the one
path that mattered. And the yields-to-a-command test was vacuous as first
written: saving pushes, so there was no pending change left and the assertion
held with the guard deleted. It now fails the automatic push first to arrange
a real queue.
2026-07-29 14:53:19 +02:00

216 lines
7.0 KiB
C#

using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// A signed-in server, without the signing in.
/// </summary>
/// <remarks>
/// Stands in for a <c>ServerConnection</c> so the shell's state machine can be driven end to end. The
/// account half stores what it is given and reports it back, because the provisioner re-reads <c>/me</c>
/// after enrolling and a stub that echoed the request would make that check meaningless. The sync half
/// applies pushes and serves them back as a change log, which is enough for the shell — the interesting
/// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
/// version checks.
/// </remarks>
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
{
private readonly List<SyncChange> log = [];
private readonly Dictionary<Guid, SyncChange> rows = [];
private KeyStatement? statement;
private byte[]? wrappedPrivateKey;
private KdfParameters? kdfParameters;
private VaultSummary? personalVault;
internal Guid UserId { get; } = Guid.Parse("0192f0c8-4444-7aaa-8bbb-dddddddddddd");
internal int EnrollmentCount { get; private set; }
internal int PushCount { get; private set; }
internal bool IsEnrolled => statement is not null;
internal int LiveRowCount => rows.Values.Count(row => row.Operation != SyncOperation.Delete);
/// <summary>When set, the next sign-in throws — how an unreachable server is exercised.</summary>
internal Exception? SignInFailure { get; set; }
/// <summary>
/// When set, every synchronisation throws.
/// </summary>
/// <remarks>
/// A server that answers but fails, as distinct from no server at all. The two are handled quite
/// differently by a background pass: one is expected and silent, the other has to not overwrite
/// whatever the user was reading.
/// </remarks>
internal Exception? SyncFailure { get; set; }
/// <inheritdoc />
public Uri ServerUrl { get; } = new("https://dodossh.example");
/// <inheritdoc />
public IAccountApi Account => this;
/// <inheritdoc />
public ISyncApi Sync => this;
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => this;
/// <inheritdoc />
public SyncOptions SyncOptions => SyncOptions.Default;
/// <inheritdoc />
public void Dispose()
{
// Nothing to release; the shell disposes this on lock and on shutdown, and both paths have to be
// safe to run more than once.
}
// ---- Identity provider ----
/// <inheritdoc />
public Task<string> AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken) =>
Task.FromResult("stub-id-token");
// ---- Account ----
/// <inheritdoc />
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) =>
Task.FromResult(new MeResponse(
UserId,
"https://idp.example/realms/dodossh",
"alice",
"alice@example.com",
"Alice Example",
EnrollmentRequired: !IsEnrolled,
KeyGeneration: statement?.KeyGeneration,
WrappedPrivateKey: wrappedPrivateKey,
KdfParameters: kdfParameters,
Vaults: personalVault is null ? [] : [personalVault]));
/// <inheritdoc />
public Task<EnrollmentResponse> EnrollAsync(
EnrollmentRequest request,
CancellationToken cancellationToken)
{
EnrollmentCount++;
statement = request.Statement;
wrappedPrivateKey = request.WrappedPrivateKey;
kdfParameters = request.KdfParameters;
personalVault = new VaultSummary(
request.PersonalVault.VaultId,
request.PersonalVault.Name,
IsPersonal: true,
TeamId: null,
KeyGeneration: 1,
Permissions: 31,
request.PersonalVault.WrappedVaultKey,
RekeyRequired: false);
return Task.FromResult(new EnrollmentResponse(
UserId,
KeyGeneration: 1,
Fingerprint: new byte[32],
request.PersonalVault.VaultId,
DeviceId: null,
KeyLogSequence: 1));
}
// ---- Sync ----
/// <inheritdoc />
public Task<SyncPullResponse> SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken)
{
if (SyncFailure is { } failure)
{
return Task.FromException<SyncPullResponse>(failure);
}
var after = request.Cursor is null
? 0
: long.Parse(request.Cursor.AsSpan("app-v1:".Length), provider: null);
var page = log.Where(change => change.ChangeSequence > after).ToList();
var next = page.Count > 0 ? page[^1].ChangeSequence : after;
return Task.FromResult(new SyncPullResponse(
page,
$"app-v1:{next}",
HasMore: false,
ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000),
CurrentKeyGeneration: 1));
}
/// <inheritdoc />
public Task<SyncPushResponse> SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken)
{
PushCount++;
var results = new List<SyncPushResult>(request.Operations.Count);
foreach (var operation in request.Operations)
{
results.Add(Apply(operation));
}
return Task.FromResult(new SyncPushResponse(results, $"app-v1:{log.Count}"));
}
private SyncPushResult Apply(SyncPushOperation operation)
{
rows.TryGetValue(operation.EntityId, out var existing);
var current = existing?.Operation == SyncOperation.Delete ? null : existing;
if (operation.ExpectedVersion != current?.Version)
{
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Conflict,
current?.Version,
current?.ChangeSequence,
current,
null);
}
var sequence = log.Count + 1;
var change = new SyncChange(
operation.EntityType,
operation.EntityId,
operation.Operation,
Version: (current?.Version ?? 0) + 1,
ChangeSequence: sequence,
Payload: operation.Operation == SyncOperation.Delete ? null : operation.Payload,
PlaintextFields: operation.Operation == SyncOperation.Delete
? null
: operation.PlaintextFields,
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + sequence));
rows[operation.EntityId] = change;
log.Add(change);
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
change.Version,
sequence,
null,
null);
}
}