159 lines
5.1 KiB
C#
159 lines
5.1 KiB
C#
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_CreatesSessionAndExecsRequestedShell()
|
|
{
|
|
using var manager = CreateManager(out var factory, out _);
|
|
|
|
var result = await manager.StartAsync(" prod-api ", " deploy ", "bash", 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.Contains("exec bash\n", 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, 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, 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, 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<TerminalReadResult> 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<string> _output = new();
|
|
|
|
public List<string> 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<AuditEvent> 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");
|
|
}
|
|
}
|