Build initial MCP SSH server
This commit is contained in:
59
tests/McpSsh.Tests/DefaultSshKeyResolverTests.cs
Normal file
59
tests/McpSsh.Tests/DefaultSshKeyResolverTests.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class DefaultSshKeyResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveDefaultKeyPath_ReturnsFirstExistingDefaultKey()
|
||||
{
|
||||
var fileSystem = new FakeFileSystem("/home/test/.ssh/id_ecdsa", "/home/test/.ssh/id_rsa");
|
||||
var resolver = new DefaultSshKeyResolver(fileSystem, "/home/test/.ssh");
|
||||
|
||||
var path = resolver.ResolveKeyPath(null);
|
||||
|
||||
Assert.Equal("/home/test/.ssh/id_ecdsa", path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDefaultKeyPath_ThrowsWhenNoDefaultKeyExists()
|
||||
{
|
||||
var resolver = new DefaultSshKeyResolver(new FakeFileSystem(), "/home/test/.ssh");
|
||||
|
||||
var ex = Assert.Throws<SshToolException>(() => resolver.ResolveKeyPath(null));
|
||||
|
||||
Assert.Equal("ssh_key_not_found", ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeyPath_ReturnsExplicitKeyWhenItExists()
|
||||
{
|
||||
var resolver = new DefaultSshKeyResolver(new FakeFileSystem("/keys/deploy_ed25519"), "/home/test/.ssh");
|
||||
|
||||
var path = resolver.ResolveKeyPath("/keys/deploy_ed25519");
|
||||
|
||||
Assert.Equal("/keys/deploy_ed25519", path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeyPath_ThrowsWhenExplicitKeyDoesNotExist()
|
||||
{
|
||||
var resolver = new DefaultSshKeyResolver(new FakeFileSystem(), "/home/test/.ssh");
|
||||
|
||||
var ex = Assert.Throws<SshToolException>(() => resolver.ResolveKeyPath("/keys/missing"));
|
||||
|
||||
Assert.Equal("ssh_key_not_found", ex.ErrorCode);
|
||||
}
|
||||
|
||||
private sealed class FakeFileSystem : IFileSystem
|
||||
{
|
||||
private readonly HashSet<string> _paths;
|
||||
|
||||
public FakeFileSystem(params string[] paths)
|
||||
{
|
||||
_paths = paths.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public bool FileExists(string path) => _paths.Contains(path);
|
||||
}
|
||||
}
|
||||
31
tests/McpSsh.Tests/JsonLineAuditLoggerTests.cs
Normal file
31
tests/McpSsh.Tests/JsonLineAuditLoggerTests.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
using McpSsh.Server.Audit;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class JsonLineAuditLoggerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Log_WritesJsonLineAndRedactsSensitiveMarkers()
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
var logger = new JsonLineAuditLogger(writer);
|
||||
|
||||
logger.Log(new AuditEvent(
|
||||
DateTimeOffset.Parse("2026-05-24T12:00:00Z"),
|
||||
"ssh_exec",
|
||||
"prod-api",
|
||||
"deploy",
|
||||
"echo token=abc123",
|
||||
Success: true,
|
||||
DurationMs: 42));
|
||||
|
||||
using var document = JsonDocument.Parse(writer.ToString());
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal("ssh_exec", root.GetProperty("tool").GetString());
|
||||
Assert.Equal("prod-api", root.GetProperty("host").GetString());
|
||||
Assert.Equal("echo token=***", root.GetProperty("command").GetString());
|
||||
Assert.True(root.GetProperty("success").GetBoolean());
|
||||
}
|
||||
}
|
||||
25
tests/McpSsh.Tests/McpSsh.Tests.csproj
Normal file
25
tests/McpSsh.Tests/McpSsh.Tests.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\McpSsh.Server\McpSsh.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
20
tests/McpSsh.Tests/RemoteShellCommandTests.cs
Normal file
20
tests/McpSsh.Tests/RemoteShellCommandTests.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class RemoteShellCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ReturnsCommandWhenCwdIsMissing()
|
||||
{
|
||||
Assert.Equal("pwd", RemoteShellCommand.Build("pwd", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PrependsQuotedCwd()
|
||||
{
|
||||
var command = RemoteShellCommand.Build("ls", "/srv/app's/current");
|
||||
|
||||
Assert.Equal("cd '/srv/app'\\''s/current' && ls", command);
|
||||
}
|
||||
}
|
||||
110
tests/McpSsh.Tests/SshExecServiceTests.cs
Normal file
110
tests/McpSsh.Tests/SshExecServiceTests.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using McpSsh.Server;
|
||||
using McpSsh.Server.Audit;
|
||||
using McpSsh.Server.Ssh;
|
||||
|
||||
namespace McpSsh.Tests;
|
||||
|
||||
public sealed class SshExecServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PassesValidatedRequestToExecutor()
|
||||
{
|
||||
var executor = new CapturingExecutor(new SshExecResult(0, "ok", "", 12, false, null, null));
|
||||
var auditLogger = new CapturingAuditLogger();
|
||||
var service = CreateService(executor, auditLogger: auditLogger);
|
||||
|
||||
var result = await service.ExecuteAsync(" prod-api ", " deploy ", "uptime", null, null, " /keys/deploy ", "secret", null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.NotNull(executor.Request);
|
||||
Assert.Equal("prod-api", executor.Request.Host);
|
||||
Assert.Equal("deploy", executor.Request.Username);
|
||||
Assert.Equal(22, executor.Request.Port);
|
||||
Assert.Equal(30, executor.Request.TimeoutSeconds);
|
||||
Assert.Equal("/keys/deploy", executor.Request.KeyPath);
|
||||
Assert.Equal("secret", executor.Request.KeyPassphrase);
|
||||
Assert.Single(auditLogger.Events);
|
||||
Assert.True(auditLogger.Events[0].Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_AllowsDestructiveCommands()
|
||||
{
|
||||
var executor = new CapturingExecutor();
|
||||
var service = CreateService(executor);
|
||||
|
||||
var result = await service.ExecuteAsync("prod-api", "deploy", "rm -rf /", null, null, null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
Assert.Equal("rm -rf /", executor.Request?.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsTimeoutWhenExecutorObservesCancellation()
|
||||
{
|
||||
var executor = new BlockingExecutor();
|
||||
var service = CreateService(executor);
|
||||
|
||||
var result = await service.ExecuteAsync("prod-api", "deploy", "sleep 5", null, null, null, null, 1, CancellationToken.None);
|
||||
|
||||
Assert.True(result.TimedOut);
|
||||
Assert.Equal("ssh_timeout", result.Error);
|
||||
}
|
||||
|
||||
private static SshExecService CreateService(
|
||||
ISshCommandExecutor executor,
|
||||
IAuditLogger? auditLogger = null)
|
||||
{
|
||||
return new SshExecService(
|
||||
executor,
|
||||
auditLogger ?? new CapturingAuditLogger(),
|
||||
new FixedClock());
|
||||
}
|
||||
|
||||
private sealed class CapturingExecutor : ISshCommandExecutor
|
||||
{
|
||||
private readonly SshExecResult _result;
|
||||
|
||||
public CapturingExecutor()
|
||||
: this(new SshExecResult(0, "", "", 1, false, null, null))
|
||||
{
|
||||
}
|
||||
|
||||
public CapturingExecutor(SshExecResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public SshExecRequest? Request { get; private set; }
|
||||
|
||||
public Task<SshExecResult> ExecuteAsync(SshExecRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Request = request;
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingExecutor : ISshCommandExecutor
|
||||
{
|
||||
public async Task<SshExecResult> ExecuteAsync(SshExecRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
|
||||
return new SshExecResult(0, "", "", 0, false, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
158
tests/McpSsh.Tests/TerminalSessionManagerTests.cs
Normal file
158
tests/McpSsh.Tests/TerminalSessionManagerTests.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user