using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Terminal.Tests; /// /// How long a terminal session lives, and what the workspace says about it. /// /// /// The count these tests are about is shown to a user on the unlock screen, as the disclosure that /// locking the vault leaves shells running. A wrong number there is not a cosmetic bug: it either hides /// a live connection or invents one. /// public sealed class TerminalWorkspaceTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); private static InMemoryTerminalAssetProvider StubAssets() => new(new Dictionary(StringComparer.Ordinal) { [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", ""u8.ToArray()), }); private static SshConnectionRequest Request() => new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")); [Fact] public async Task AnOpenSessionIsReportedAsLive() { var connections = new FakeConnectionFactory(); await using var workspace = new TerminalWorkspace( StubAssets(), connections, TimeProvider.System); workspace.LiveSessionCount.ShouldBe(0); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); workspace.LiveSessionCount.ShouldBe(1); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); workspace.LiveSessionCount.ShouldBe(2); } /// /// The case a naive sessions.Count gets wrong. Nothing removes the entry when the remote /// closes the channel by itself — the session is still in the dictionary, holding a connection that /// is finished — so counting entries would report a shell that exited as still running. A user /// deciding whether it is safe to walk away is the person that lie is told to. /// [Fact] public async Task ASessionWhoseRemoteHasExitedIsNotReportedAsLive() { // A shell with no output to give: its first read returns 0, which is a remote closing the // channel, so the pump finishes on its own with nobody asking it to. var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = new TerminalWorkspace( StubAssets(), connections, TimeProvider.System); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await WaitUntilAsync(() => workspace.LiveSessionCount == 0); } [Fact] public async Task ClosingASessionEndsItAndDisposesItsConnection() { var connections = new FakeConnectionFactory(); await using var workspace = new TerminalWorkspace( StubAssets(), connections, TimeProvider.System); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await workspace.CloseSessionAsync(sessionId); workspace.LiveSessionCount.ShouldBe(0); connections.Connections.ShouldHaveSingleItem().IsDisposed.ShouldBeTrue(); } /// /// Disposal is the one path that does close sessions, because it is process shutdown. Asserted so /// that the SSH connections are known to be released rather than assumed to be. /// [Fact] public async Task DisposingTheWorkspaceClosesEverySession() { var connections = new FakeConnectionFactory(); var workspace = new TerminalWorkspace(StubAssets(), connections, TimeProvider.System); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, TestContext.Current.CancellationToken); await workspace.DisposeAsync(); workspace.LiveSessionCount.ShouldBe(0); connections.Connections.Count.ShouldBe(2); connections.Connections.ShouldAllBe(connection => connection.IsDisposed); } /// /// Polled rather than awaited on a task, because the point is what an observer of the property /// sees: the pump ends on a thread of its own, and the count has to catch up without anyone /// telling it to. /// private static async Task WaitUntilAsync(Func condition) { var deadline = TimeProvider.System.GetUtcNow() + Timeout; while (TimeProvider.System.GetUtcNow() < deadline) { if (condition()) { return; } await Task.Delay(20, TestContext.Current.CancellationToken); } throw new TimeoutException($"The condition was still false after {Timeout}."); } }