using DodoSSH.Client.Ssh; namespace DodoSSH.Client.App.Tests; /// /// An SSH stack that connects to nothing. /// /// /// The shell suite is about what the view models do, and the real factory would need a reachable /// sshd — which DodoSSH.Client.Ssh.Tests already covers against a container. What this makes /// testable is everything the connect path does around the connection. /// internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSessionFactory { /// Thrown instead of connecting, when set. Used for the host-key paths. internal Exception? Failure { get; set; } /// /// Holds a connection open until it is completed, when set. /// /// /// A handshake takes as long as a network takes, and connecting is deliberately no longer allowed to /// hold the window still while it does — so there is now behaviour that only exists during a /// connection: a tab in the strip with no session behind it. This is how a test gets to look at that /// moment rather than at the two on either side of it. /// internal TaskCompletionSource? Gate { get; set; } /// Requests this factory was asked for, in order. internal List Requests { get; } = []; /// Every shell this factory has opened, in order. /// /// Exists so a test can reach one and drop it — see . There was no /// reason to hold them while the only ending a session had was the tab being closed, which the shell can /// ask for directly. /// internal List Shells { get; } = []; /// Requests for a file-transfer session, in order. /// /// Kept apart from deliberately: file transfer is a separate connection, and a /// test asserting that opening a terminal did not also open one would have nothing to look at if the two /// shared a list. /// internal List SftpRequests { get; } = []; /// public async Task ConnectAsync( SshConnectionRequest request, IProgress? progress, CancellationToken cancellationToken) { Requests.Add(request); // Before the gate rather than after it, which is what makes this fake useful for the connecting // card: a test that holds Gate open is a connection stuck partway through, and the step list has to // show it stuck on a named step rather than on none. progress?.Report(SshConnectionPhase.Reaching); if (Gate is { } gate) { await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } if (Failure is { } failure) { throw failure; } // Only on the way to succeeding. A failure reported as having authenticated would let a test pass // while the card showed a refused connection getting one step further than it did. progress?.Report(SshConnectionPhase.CheckingHostKey); progress?.Report(SshConnectionPhase.Authenticating); return new FakeSshConnection(request, Shells); } /// The file-transfer session this factory last handed out, or null when it never has. /// /// Kept so a test can read back what the screen actually asked the host to do — which path was renamed, /// which was deleted, which directories were listed. The screen reports all three in its own status /// line, and a suite that asserted only on that would be checking a sentence this code wrote about /// itself rather than the operation it claims to have performed. /// internal FakeSftpSession? OpenedSftp { get; private set; } /// public Task OpenSftpAsync( SshConnectionRequest request, CancellationToken cancellationToken) { SftpRequests.Add(request); if (Failure is { } failure) { return Task.FromException(failure); } OpenedSftp = new FakeSftpSession(request); return Task.FromResult(OpenedSftp); } } /// A remote filesystem with one directory in it. /// /// Enough for the shell suite, which is about what the screen does around a session rather than about /// moving bytes. The queue's own behaviour is covered against a fuller fake in /// DodoSSH.Client.Transfer.Tests, and the real subsystem against a container in /// DodoSSH.Client.Ssh.Tests. /// internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSession { /// public bool IsConnected { get; private set; } = true; /// public string? TransportFailure { get; private set; } /// public event EventHandler? ConnectionLost; /// /// Ends this session the way a rebooted host does: the connection is gone and it says so. /// /// /// Latched before the announcement, which is the ordering the real session promises — a screen reading /// the reason out of the event would see null if a fake did it the other way round. /// internal void Drop(string reason) { TransportFailure = reason; IsConnected = false; ConnectionLost?.Invoke(this, EventArgs.Empty); } /// public HostKeyPresentation HostKey { get; } = new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake"); /// public string Cipher { get; } = "aes256-gcm@openssh.com"; /// public string HomeDirectory => $"/home/{request.Username}"; /// Every directory this session has been asked to list, in order. /// /// The evidence that a refresh happened at all. A listing whose contents never change — this one always /// answers the same single row — leaves no other trace of having been re-read, which is exactly the /// thing the automatic refresh after a transfer has to be proved to do. /// internal List Listed { get; } = []; /// Every rename this session has been asked for, as it was asked. internal List<(string From, string To)> Renamed { get; } = []; /// Every path this session has been asked to delete. internal List Deleted { get; } = []; /// Every mode this session has been asked to set, as it was asked. /// /// The evidence the screen's own status line cannot be: that sentence is code describing itself, and /// what matters is which nine bits actually went to the host. /// internal List<(string Path, int Mode)> PermissionsSet { get; } = []; /// Every timestamp this session has been asked to set. internal List<(string Path, DateTimeOffset When)> TimesSet { get; } = []; /// public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) { PermissionsSet.Add((path, mode)); return Task.CompletedTask; } /// public Task SetLastWriteTimeAsync( string path, DateTimeOffset lastWriteTimeUtc, CancellationToken cancellationToken) { TimesSet.Add((path, lastWriteTimeUtc)); return Task.CompletedTask; } /// public Task> ListAsync(string path, CancellationToken cancellationToken) { Listed.Add(path); return Task.FromResult>( [ new SftpEntry( "notes.txt", SftpPath.Combine(path, "notes.txt"), SftpEntryKind.File, 12, DateTimeOffset.UnixEpoch, "-rw-r--r--"), ]); } /// /// Paths this session admits to already having, and what it says about each. /// /// /// ◆ Empty by default, and that is load-bearing rather than lazy: the transfer queue asks /// whether a destination is taken and refuses to overwrite one that is, so a /// fake answering yes for everything would make every upload in this suite fail. What needs a real /// answer is editing, which stats the file before each write-back to see whether it moved — so a test /// about that puts the one path it is about in here. /// internal Dictionary Existing { get; } = new(StringComparer.Ordinal); /// What the last write-back actually sent, so an edit's bytes are observable. internal byte[]? Written { get; private set; } /// public Task StatAsync(string path, CancellationToken cancellationToken) => Task.FromResult(Existing.TryGetValue(path, out var entry) ? entry : null); /// public Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken) => Task.FromResult(new MemoryStream("hello there\n"u8.ToArray(), writable: false)); /// /// /// ◆ A write updates only for a path already in it, which is the difference /// between modelling a server and breaking every other test in the suite: the transfer queue writes to /// part files and stats them to decide whether there is anything to resume from, so a fake that /// remembered every write would start answering yes to questions it currently answers no to. What is in /// is what a test deliberately put there. /// public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) => Task.FromResult(new RecordingStream(bytes => { Written = bytes; if (Existing.TryGetValue(path, out var before)) { Existing[path] = before with { Length = bytes.Length, LastWriteTimeUtc = before.LastWriteTimeUtc.AddSeconds(1), }; } })); /// Every directory this session was asked to make, in the order it was asked. /// /// The evidence a folder upload actually built the tree before it queued the files. Order matters and is /// asserted on: a parent has to exist before its child is asked for, and shortest-first is what /// guarantees it. /// internal List Created { get; } = []; /// public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) { Created.Add(path); return Task.CompletedTask; } /// public Task DeleteAsync(string path, CancellationToken cancellationToken) { Deleted.Add(path); return Task.CompletedTask; } /// public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) { Renamed.Add((fromPath, toPath)); return Task.CompletedTask; } /// public ValueTask DisposeAsync() { IsConnected = false; return ValueTask.CompletedTask; } } internal sealed class FakeSshConnection(SshConnectionRequest request, List shells) : ISshConnection { /// public bool IsConnected { get; private set; } = true; /// public HostKeyPresentation HostKey { get; } = new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake"); /// public string Cipher { get; } = "aes256-gcm@openssh.com"; /// public Task OpenShellAsync( TerminalSize size, CancellationToken cancellationToken) { var shell = new FakeSshShellSession(); shells.Add(shell); return Task.FromResult(shell); } /// public ValueTask DisposeAsync() { IsConnected = false; return ValueTask.CompletedTask; } } /// A write stream that hands its contents over when it is closed. /// /// A cannot be read after disposal, and disposal is exactly when a write is /// finished — so a fake that handed one back could observe a transfer starting and never what it sent. /// internal sealed class RecordingStream(Action onClosed) : MemoryStream { private bool handed; /// protected override void Dispose(bool disposing) { if (disposing && !handed) { handed = true; onClosed(ToArray()); } base.Dispose(disposing); } } /// A shell that is open, silent and closes only when it is told to. /// /// blocks rather than returning 0. Returning 0 means the remote closed the /// channel, which would end the session the moment it was opened and make the test assert against a /// connection that had already gone. and are the two ways out. /// internal sealed class FakeSshShellSession : ISshShellSession { private readonly CancellationTokenSource closed = new(); /// public bool IsOpen { get; private set; } = true; /// public string? TransportFailure { get; private set; } /// /// Ends this shell the way a rebooted machine does: the read stops, with a transport failure latched. /// /// /// Latched before the read is released, which is the ordering the real session promises and the whole /// reason the distinction is race-free — see . A fake /// that set it afterwards would be testing a guarantee the product does not make. /// internal void Drop(string reason) { TransportFailure = reason; closed.Cancel(); } /// Ends this shell the way exit does: the read stops, with nothing wrong underneath. internal void Exit() => closed.Cancel(); /// public async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) { try { await Task.Delay(Timeout.InfiniteTimeSpan, closed.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { // This shell ending, not the pump being asked to stop. Reported as end of stream rather than // rethrown, because that is what a real read does for both of the endings above: the channel // stops producing bytes, and what happened to the transport is told by the latch, not by an // exception coming out of here. return 0; } cancellationToken.ThrowIfCancellationRequested(); return 0; } /// public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => ValueTask.CompletedTask; /// public void Resize(TerminalSize size) { } /// public async ValueTask DisposeAsync() { IsOpen = false; await closed.CancelAsync().ConfigureAwait(false); closed.Dispose(); } }