using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Session.Tests;
///
/// An in-memory account server: just-in-time provisioning, enrollment, and /me.
///
///
/// Stores what a real server stores and reports it back the same way, because that round trip is the
/// thing under test — the provisioner deliberately re-reads /me after enrolling rather than
/// caching what it believes it sent, and a stub that echoed the request would make that check vacuous.
///
/// It does not verify the identity-provider token or the grant signature. Those are the server's job and
/// are covered against a real JWT pipeline in DodoSSH.Api.Tests; repeating them here would test
/// this file rather than the client.
///
///
internal sealed class FakeAccountServer : IAccountApi
{
private KeyStatement? statement;
private byte[]? wrappedPrivateKey;
private KdfParameters? kdfParameters;
private VaultSummary? personalVault;
internal Guid UserId { get; } = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");
internal static string Issuer => "https://idp.example/realms/dodossh";
internal static string Subject => "alice";
/// The enrollment request as received, so a test can assert what was actually sent.
internal EnrollmentRequest? LastEnrollment { get; private set; }
internal int EnrollmentCount { get; private set; }
internal int MeCount { get; private set; }
/// Whether an identity key has been published.
internal bool IsEnrolled => statement is not null;
///
/// Device wraps registered after enrollment, keyed on the device public key.
///
///
/// Kept so a test can assert that the wrap the server received is the one the client claimed to send.
/// The server cannot open it and neither does this, which is the point: possession is proved by
/// producing it, not by anything either side checks.
///
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);
///
public Task GetMeAsync(CancellationToken cancellationToken)
{
MeCount++;
return Task.FromResult(new MeResponse(
UserId,
Issuer,
Subject,
"alice@example.com",
"Alice",
EnrollmentRequired: !IsEnrolled,
KeyGeneration: statement?.KeyGeneration,
WrappedPrivateKey: wrappedPrivateKey,
KdfParameters: kdfParameters,
Vaults: personalVault is null ? [] : [personalVault]));
}
///
public Task EnrollAsync(
EnrollmentRequest request,
CancellationToken cancellationToken)
{
EnrollmentCount++;
LastEnrollment = request;
if (IsEnrolled)
{
// The real server answers 409 with ProblemCodes.AlreadyEnrolled. Reproduced because the
// provisioner is supposed to never get here — it reads /me first — and a test that changed
// that should fail loudly rather than quietly enroll twice.
throw new DodoSshApiException(
System.Net.HttpStatusCode.Conflict,
ProblemCodes.AlreadyEnrolled,
"This account already has an identity key.");
}
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: request.DevicePublicKey is null ? null : Guid.CreateVersion7(),
KeyLogSequence: 1));
}
///
///
/// Refuses before enrollment, as the real endpoint does through Auth.EnrolledPolicy: there is no
/// bundle to have wrapped yet, so a wrap arriving here would be a wrap of something else. Idempotent on
/// the public key, again matching the real one.
///
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;
// The same id for the same public key, which the real service does and this fake used to claim in a
// comment while handing back a fresh Guid every call. That difference is invisible until something
// revokes by id, at which point a test would be revoking an id the server never issued.
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);
// The wrap goes with it, as the foreign key's cascade does on the real server. A fake that kept the
// wrap would let a test claiming to prove revocation pass while the dangerous half survived.
RegisteredDevices.Remove(key);
return Task.FromResult(true);
}
/// Drops the vault grant, as a rekey does until it is re-issued.
internal void RevokeVaultGrant() =>
personalVault = personalVault is null
? null
: personalVault with { WrappedVaultKey = null, RekeyRequired = true };
}
///
/// Stands in for the identity provider's signature over a key statement.
///
///
/// Records the nonce it was asked for. That the nonce is the statement's hash is what makes the binding
/// meaningful, and it is asserted in ClientEnrollmentTests; here it only needs to exist.
///
internal sealed class StubKeyBinding : IKeyBindingAuthorizer
{
internal string? RequestedNonce { get; private set; }
public Task AuthorizeKeyBindingAsync(
string bindingNonce,
CancellationToken cancellationToken)
{
RequestedNonce = bindingNonce;
return Task.FromResult("stub-id-token");
}
}
///
/// A server with no changes in it.
///
///
/// Enough to prove the session composes a working sync engine. The interesting sync behaviour lives in
/// DodoSSH.Client.Sync.Tests against a server that enforces version checks; duplicating that here
/// would be a third implementation of the same decision table.
///
internal sealed class EmptySyncApi : ISyncApi
{
internal int PushCount { get; private set; }
public Task SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken) =>
Task.FromResult(new SyncPullResponse(
[],
request.Cursor ?? "empty-v1:0",
HasMore: false,
ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000),
CurrentKeyGeneration: 1));
public Task SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken)
{
PushCount++;
return Task.FromResult(new SyncPushResponse(
[.. request.Operations.Select((operation, index) => new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
Version: (operation.ExpectedVersion ?? 0) + 1,
ChangeSequence: index + 1,
ServerEntity: null,
Detail: null))],
"empty-v1:0"));
}
}