Public Access
Seven files needed a hand. Most were two branches adding something in the same place, but three were one branch changing what the other had moved or renamed, and those are the ones worth reading. The shell keeps both new fields and both constructor lines: the connection recorder this branch built and the teams view model main did. Where main put a teams load inside OnScreenChanged, it now sits beside the logs refresh rather than inside RaiseSurfaceState — this branch extracted that notification block and it is called from two properties, so a screen-specific side effect in there would fire on every terminal switch as well. Main gave four row types a vault id and a vault name, and this branch had moved one of them — KnownHostRowViewModel — into its own file when the pinned keys became a screen. Git resolved that as "deleted here, modified there" and took the delete, which compiles as long as nobody looks: the moved copy still had the two-argument constructor and the call site had grown to four. Carried over by hand, along with the ordering the pins list now does on them. The status line's quiet rule was the subtle one. Main extracted it into IsWorthReporting; this branch had changed the same condition to read item counts rather than raw ones, because every user action queues a log entry a moment later and this machine reads its own entries back on the next pull. Take main's structure and the merge builds, passes, and silently restores a bug this branch existed partly to fix — every save's message overwritten a second after it appears. The method now reads PulledItems and PushedItems, with the reason in its remarks. Two conflicts were prose that had gone stale rather than code. The keychain screen's comment said team vaults are refused by the server's access service, which was true when it was written and is not now; main's replacement stands, in this branch's vocabulary. The design-gaps row for groups was claimed by both — real host groups here, per-vault headings there — and they are different things, so both rows stay and the difference is stated: a group is a shelf the user chose, a vault is who can read the item. One defect the tests found and the compiler could not. Generating a key opens the same editor as pasting one, but not through NewKey — so it never set the target vault main added, and a generated key was filed into whatever vault was edited last, or none. Both key-generation tests failed on it. Fixed where the editor opens, with the reason recorded there. One gap is left deliberately and is written down rather than half-built. Hosts, keys, credentials and pins are read across every vault this session holds a key for; groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar already shows for a group that has been deleted — but closing it needs a vault id on every group row for rename and delete, and a way to tell two vaults' identically-named groups apart under a layout with one heading per group. Both are worth doing and neither is a merge's business. It is in the remarks on ReloadGroupsAsync and in docs/design-import-gaps.md. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1282 tests, including the end-to-end suite against real containers.
313 lines
11 KiB
C#
313 lines
11 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 partial class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
|
{
|
|
private readonly List<SyncChange> log = [];
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// How many of the <em>user's</em> items are live on this server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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 <see cref="LogRowCount"/> is for.
|
|
/// </remarks>
|
|
internal int LiveRowCount => rows.Values.Count(row =>
|
|
row.Operation != SyncOperation.Delete && !IsLog(row.EntityType));
|
|
|
|
/// <summary>How many log entries are live on this server, of either kind.</summary>
|
|
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;
|
|
|
|
/// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary>
|
|
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>The id issued for each registered public key, so revocation has something to name.</summary>
|
|
private readonly Dictionary<string, Guid> deviceIds = new(StringComparer.Ordinal);
|
|
|
|
/// <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;
|
|
|
|
/// <summary>
|
|
/// The refresh token this "connection" holds.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public string? RefreshToken { get; set; } = "refresh-token-1";
|
|
|
|
/// <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,
|
|
|
|
// 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]));
|
|
|
|
/// <inheritdoc />
|
|
public Task<EnrollmentResponse> 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));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Records the wrap so a test can assert it reached the server, and refuses before enrollment as the
|
|
/// real endpoint's <c>Auth.EnrolledPolicy</c> does.
|
|
/// </remarks>
|
|
public Task<RegisterDeviceResponse> 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));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> 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 ----
|
|
|
|
/// <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.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);
|
|
}
|
|
}
|