using System.Text; using DodoSSH.Client.Terminal; namespace DodoSSH.Client.Ssh.Tests; /// /// The pump against a real shell, with the transport replaced by a recorder. /// /// /// Sits between the pump's unit tests, which use a fake shell, and the full end-to-end test, which /// adds the loopback socket. Its value is diagnostic: when output does not reach a renderer, this /// says whether the pump and SSH.NET are producing anything at all. /// [Collection(SshCollection.Name)] public sealed class PumpOverRealSshTests(SshServerFixture fixture) { /// Trusts the container's host key, then connects. First contact is refused by design. private async Task ConnectTrustedAsync() { var knownHosts = new InMemoryKnownHostStore(); var factory = new SshNetConnectionFactory(knownHosts); var request = new SshConnectionRequest( fixture.Host, fixture.Port, SshServerFixture.Username, new SshPasswordCredential(SshServerFixture.Password)); var unknown = await Should.ThrowAsync(async () => await factory.ConnectAsync(request, TestContext.Current.CancellationToken)); await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken); return await factory.ConnectAsync(request, TestContext.Current.CancellationToken); } [Fact] public async Task ThePump_ForwardsRealShellOutput() { await using var connection = await ConnectTrustedAsync(); var shell = await connection.OpenShellAsync( new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken); var transport = new CountingTransport(); await using var pump = new TerminalSessionPump( 1, shell, transport, TimeProvider.System, new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) }); transport.Pump = pump; var run = pump.RunAsync(TestContext.Current.CancellationToken); // Marker split so the PTY's echo of the command line does not satisfy the match. await pump.WriteInputAsync( Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"\n"), TestContext.Current.CancellationToken); var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20); while (TimeProvider.System.GetUtcNow() < deadline && !transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal)) { await Task.Delay(50, TestContext.Current.CancellationToken); } transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal).ShouldBeTrue( $"BytesRead={pump.BytesRead}, FramesSent={pump.FramesSent}, " + $"OutputFrames={transport.OutputFrames}, Text=<{transport.Text}>"); await pump.DisposeAsync(); try { await run; } catch (OperationCanceledException) { // Expected: disposing the pump cancels its run. } } /// Accumulates output and acknowledges it, as a keeping-up renderer would. private sealed class CountingTransport : ITerminalTransport { private readonly StringBuilder text = new(); private readonly Lock gate = new(); internal TerminalSessionPump? Pump { get; set; } internal int OutputFrames { get; private set; } internal string Text { get { lock (gate) { return text.ToString(); } } } public ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken) { if (!TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload) || opcode != (byte)TerminalServerOpcode.Output) { return ValueTask.CompletedTask; } lock (gate) { OutputFrames++; text.Append(Encoding.UTF8.GetString(payload)); } Pump?.Acknowledge((uint)payload.Length); return ValueTask.CompletedTask; } } }