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); /// 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); if (!RegisteredDevices.TryAdd(key, request.WrappedPrivateKey)) { RegisteredDevices[key] = request.WrappedPrivateKey; } return Task.FromResult(new RegisterDeviceResponse( DeviceId: Guid.CreateVersion7(), EnrolledAt: DateTimeOffset.UnixEpoch)); } /// 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")); } }