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;
///
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));
}
/// 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"));
}
}