using DodoSSH.Client.Api; using DodoSSH.Client.Auth; using DodoSSH.Client.Session; using DodoSSH.Client.Sync; using DodoSSH.Contracts; namespace DodoSSH.Client.App.Tests; /// /// A signed-in server, without the signing in. /// /// /// Stands in for a ServerConnection 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 /me /// 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 DodoSSH.Client.Sync.Tests against a server that enforces /// version checks. /// internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer { private readonly List log = []; private readonly Dictionary 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); /// When set, the next sign-in throws — how an unreachable server is exercised. internal Exception? SignInFailure { get; set; } /// /// When set, every synchronisation throws. /// /// /// 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. /// internal Exception? SyncFailure { get; set; } /// public Uri ServerUrl { get; } = new("https://dodossh.example"); /// public IAccountApi Account => this; /// public ISyncApi Sync => this; /// public IKeyBindingAuthorizer KeyBinding => this; /// public SyncOptions SyncOptions => SyncOptions.Default; /// 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 ---- /// public Task AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken) => Task.FromResult("stub-id-token"); // ---- Account ---- /// public Task 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])); /// public Task 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 ---- /// public Task SyncPullAsync( Guid vaultId, SyncPullRequest request, CancellationToken cancellationToken) { if (SyncFailure is { } failure) { return Task.FromException(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)); } /// public Task SyncPushAsync( Guid vaultId, SyncPushRequest request, CancellationToken cancellationToken) { PushCount++; var results = new List(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); } }