using System.Collections.Concurrent; using McpSsh.Server; using McpSsh.Server.Audit; using McpSsh.Server.Terminal; namespace McpSsh.Tests; public sealed class TerminalSessionManagerTests { [Fact] public async Task StartAsync_CreatesSessionWithoutWritingShellSetup() { using var manager = CreateManager(out var factory, out _); var result = await manager.StartAsync(" prod-api ", " deploy ", null, null, null, "/keys/id", "secret", null, CancellationToken.None); Assert.Null(result.Error); Assert.StartsWith("term_", result.SessionId); Assert.NotNull(factory.Request); Assert.Equal("prod-api", factory.Request.Host); Assert.Equal("deploy", factory.Request.Username); Assert.Equal("/keys/id", factory.Request.KeyPath); Assert.Equal("secret", factory.Request.KeyPassphrase); Assert.Empty(factory.Connection.Writes); } [Fact] public async Task Write_SendsInputToActiveSession() { using var manager = CreateManager(out var factory, out _); var start = await manager.StartAsync("prod-api", "deploy", null, null, null, null, null, null, CancellationToken.None); var result = manager.Write(start.SessionId!, "uptime\n"); Assert.True(result.Accepted); Assert.Contains("uptime\n", factory.Connection.Writes); } [Fact] public async Task Read_DrainsBufferedOutputAndReportsTruncation() { using var manager = CreateManager(out var factory, out _); var start = await manager.StartAsync("prod-api", "deploy", null, null, null, null, null, null, CancellationToken.None); factory.Connection.QueueOutput("abcdef"); var first = await ReadUntilOutputAsync(manager, start.SessionId!, 3); var second = manager.Read(start.SessionId!, 10); Assert.Equal("abc", first.Output); Assert.True(first.Truncated); Assert.Equal("def", second.Output); Assert.False(second.Truncated); } [Fact] public void Write_ReturnsNotFoundForMissingSession() { using var manager = CreateManager(out _, out _); var result = manager.Write("term_missing", "pwd\n"); Assert.False(result.Accepted); Assert.Equal("terminal_session_not_found", result.Error); } [Fact] public async Task Stop_DisposesAndRemovesSession() { using var manager = CreateManager(out var factory, out _); var start = await manager.StartAsync("prod-api", "deploy", null, null, null, null, null, null, CancellationToken.None); var result = manager.Stop(start.SessionId!); var writeAfterStop = manager.Write(start.SessionId!, "pwd\n"); Assert.True(result.Stopped); Assert.True(factory.Connection.Disposed); Assert.Equal("terminal_session_not_found", writeAfterStop.Error); } private static TerminalSessionManager CreateManager(out FakeTerminalConnectionFactory factory, out CapturingAuditLogger auditLogger) { factory = new FakeTerminalConnectionFactory(); auditLogger = new CapturingAuditLogger(); return new TerminalSessionManager(factory, auditLogger, new FixedClock()); } private static async Task ReadUntilOutputAsync(TerminalSessionManager manager, string sessionId, int maxBytes) { for (var attempt = 0; attempt < 20; attempt++) { var result = manager.Read(sessionId, maxBytes); if (result.Output.Length > 0) { return result; } await Task.Delay(50); } return manager.Read(sessionId, maxBytes); } private sealed class FakeTerminalConnectionFactory : ITerminalConnectionFactory { public FakeTerminalConnection Connection { get; } = new(); public TerminalStartRequest? Request { get; private set; } public ITerminalConnection Create(TerminalStartRequest request) { Request = request; return Connection; } } private sealed class FakeTerminalConnection : ITerminalConnection { private readonly ConcurrentQueue _output = new(); public List Writes { get; } = []; public bool Disposed { get; private set; } public bool DataAvailable => !_output.IsEmpty; public string ReadAvailable() { return _output.TryDequeue(out var output) ? output : string.Empty; } public void Write(string input) { Writes.Add(input); } public void QueueOutput(string output) { _output.Enqueue(output); } public void Dispose() { Disposed = true; } } private sealed class CapturingAuditLogger : IAuditLogger { public List Events { get; } = []; public void Log(AuditEvent auditEvent) { Events.Add(auditEvent); } } private sealed class FixedClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-05-24T12:00:00Z"); } }