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 partial class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
{
private readonly List log = [];
///
/// Keyed on the entity type as well as the id, as the server's tables and the client's cache both are.
/// Ids are UUIDv7 so a collision between two types will not happen by accident — but a fake that would
/// treat a host and a key with one id as one row is a fake that could make a real bug pass.
///
private readonly Dictionary<(SyncEntityType Type, Guid EntityId), 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;
///
/// How many of the user's items are live on this server.
///
///
/// Log entries are excluded, and every assertion that uses this was written before they existed and
/// means exactly what it says: "the host reached the server". Counting the connection and activity
/// entries alongside them would make a number about somebody's keychain depend on how many times they
/// had connected — which is what is for.
///
internal int LiveRowCount => rows.Values.Count(row =>
row.Operation != SyncOperation.Delete && !IsLog(row.EntityType));
/// How many log entries are live on this server, of either kind.
internal int LogRowCount => rows.Values.Count(row =>
row.Operation != SyncOperation.Delete && IsLog(row.EntityType));
private static bool IsLog(SyncEntityType type) =>
type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
/// Device wraps registered after enrollment, keyed on the device public key.
internal Dictionary RegisteredDevices { get; } = new(StringComparer.Ordinal);
/// The id issued for each registered public key, so revocation has something to name.
private readonly Dictionary deviceIds = new(StringComparer.Ordinal);
/// 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;
///
/// The refresh token this "connection" holds.
///
///
/// Settable, because rotation is the half of remembering a sign-in that is easy to get wrong: a shell
/// that persisted the token it first saw would leave a rotating provider refusing the next launch. A
/// test changes this and asserts the new value reaches the cache.
///
public string? RefreshToken { get; set; } = "refresh-token-1";
///
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,
// Team vaults alongside the personal one, in the order the real /me returns them: this is
// where a vault somebody shared arrives, and a fake that only ever reported the personal one
// would make a refresh that admits a new vault untestable.
Vaults: personalVault is null ? [] : [personalVault, .. teamVaults.Values]));
///
public Task EnrollAsync(
EnrollmentRequest request,
CancellationToken cancellationToken)
{
EnrollmentCount++;
statement = request.Statement;
wrappedPrivateKey = request.WrappedPrivateKey;
kdfParameters = request.KdfParameters;
// The enrolling account joins the directory and the key log, as it does on the real server. Both
// are what a later share reads: this client verifies its own entry as part of verifying anyone's.
RegisterSelf(request.Statement, request.StatementSignature);
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));
}
///
///
/// Records the wrap so a test can assert it reached the server, and refuses before enrollment as the
/// real endpoint's Auth.EnrolledPolicy does.
///
public Task RegisterDeviceAsync(
RegisterDeviceRequest request,
CancellationToken cancellationToken)
{
if (!IsEnrolled)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.Forbidden,
ProblemCodes.EnrollmentRequired,
"This account has no identity key yet.");
}
var key = Convert.ToHexString(request.PublicKey);
RegisteredDevices[key] = request.WrappedPrivateKey;
// One id per public key, as the real service issues, so a revocation can name the device that was
// actually registered rather than one this fake invented on the way past.
if (!deviceIds.TryGetValue(key, out var deviceId))
{
deviceId = Guid.CreateVersion7();
deviceIds[key] = deviceId;
}
return Task.FromResult(new RegisterDeviceResponse(deviceId, DateTimeOffset.UnixEpoch));
}
///
public Task RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken)
{
var key = deviceIds.FirstOrDefault(entry => entry.Value == deviceId).Key;
if (key is null)
{
return Task.FromResult(false);
}
deviceIds.Remove(key);
// With its wrap, as the foreign key's cascade does on the real server.
RegisteredDevices.Remove(key);
return Task.FromResult(true);
}
// ---- 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.EntityType, 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.EntityType, operation.EntityId)] = change;
log.Add(change);
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
change.Version,
sequence,
null,
null);
}
}