221 lines
8.4 KiB
C#
221 lines
8.4 KiB
C#
using System.Net.WebSockets;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using TermRemoteCtl.Agent.Terminal;
|
|
|
|
namespace TermRemoteCtl.Agent.IntegrationTests;
|
|
|
|
public sealed class SessionFlowTests
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
[Fact]
|
|
public async Task Create_Attach_Reconnect_Sequence_Returns_Consistent_Session_Metadata()
|
|
{
|
|
await using var fixture = new AgentFixture();
|
|
using var client = fixture.CreateClient();
|
|
|
|
var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" });
|
|
createResponse.EnsureSuccessStatusCode();
|
|
|
|
var created = await createResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
|
|
Assert.NotNull(created);
|
|
|
|
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
|
|
Assert.NotNull(listedSessions);
|
|
Assert.Contains(listedSessions!, session => session.SessionId == created!.SessionId && session.Name == created.Name && session.Status == created.Status && session.CreatedAtUtc == created.CreatedAtUtc && session.UpdatedAtUtc == created.UpdatedAtUtc);
|
|
|
|
var initialSnapshot = created;
|
|
|
|
using (var firstSocket = await fixture.ConnectTerminalAsync(created.SessionId))
|
|
{
|
|
var attached = await ReceiveTextAsync(firstSocket);
|
|
var attachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(attached, JsonOptions);
|
|
|
|
Assert.NotNull(attachedPayload);
|
|
Assert.Equal(created.SessionId, attachedPayload!.SessionId);
|
|
Assert.Equal("attached", attachedPayload.Type);
|
|
}
|
|
|
|
using (var secondSocket = await fixture.ConnectTerminalAsync(created.SessionId))
|
|
{
|
|
var reattached = await ReceiveTextAsync(secondSocket);
|
|
var reattachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(reattached, JsonOptions);
|
|
|
|
Assert.NotNull(reattachedPayload);
|
|
Assert.Equal(initialSnapshot.SessionId, reattachedPayload!.SessionId);
|
|
Assert.Equal("attached", reattachedPayload.Type);
|
|
}
|
|
|
|
var replayedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
|
|
Assert.NotNull(replayedSessions);
|
|
Assert.Contains(replayedSessions!, session => session.SessionId == initialSnapshot.SessionId && session.Name == initialSnapshot.Name && session.Status == initialSnapshot.Status && session.CreatedAtUtc == initialSnapshot.CreatedAtUtc && session.UpdatedAtUtc == initialSnapshot.UpdatedAtUtc);
|
|
Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_Removes_Session_From_List_And_Stops_Host()
|
|
{
|
|
await using var fixture = new AgentFixture();
|
|
using var client = fixture.CreateClient();
|
|
|
|
var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" });
|
|
createResponse.EnsureSuccessStatusCode();
|
|
|
|
var created = await createResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
|
|
Assert.NotNull(created);
|
|
|
|
using (var socket = await fixture.ConnectTerminalAsync(created!.SessionId))
|
|
{
|
|
_ = await ReceiveTextAsync(socket);
|
|
}
|
|
|
|
var deleteResponse = await client.DeleteAsync($"/api/sessions/{created.SessionId}");
|
|
deleteResponse.EnsureSuccessStatusCode();
|
|
|
|
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
|
|
Assert.NotNull(listedSessions);
|
|
Assert.DoesNotContain(listedSessions!, session => session.SessionId == created.SessionId);
|
|
Assert.Equal(1, fixture.SessionHost.StopCountFor(created.SessionId));
|
|
}
|
|
|
|
private static async Task<string> ReceiveTextAsync(WebSocket socket)
|
|
{
|
|
var buffer = new byte[4096];
|
|
using var stream = new MemoryStream();
|
|
|
|
while (true)
|
|
{
|
|
var result = await socket.ReceiveAsync(buffer, CancellationToken.None);
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
throw new InvalidOperationException("Socket closed before a text frame was received.");
|
|
}
|
|
|
|
stream.Write(buffer, 0, result.Count);
|
|
if (result.EndOfMessage)
|
|
{
|
|
return Encoding.UTF8.GetString(stream.ToArray());
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class AgentFixture : WebApplicationFactory<Program>
|
|
{
|
|
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
|
|
private readonly RecordingSessionHost _sessionHost = new();
|
|
|
|
public RecordingSessionHost SessionHost => _sessionHost;
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
builder.UseEnvironment("Development");
|
|
builder.ConfigureAppConfiguration((_, configBuilder) =>
|
|
{
|
|
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Agent:DataRoot"] = _dataRoot,
|
|
["Agent:BindAddress"] = "127.0.0.1",
|
|
["Agent:HttpsPort"] = "9443",
|
|
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
|
|
["Agent:RingBufferLineLimit"] = "4000"
|
|
});
|
|
});
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
services.RemoveAll<ISessionHost>();
|
|
services.AddSingleton<ISessionHost>(_sessionHost);
|
|
});
|
|
}
|
|
|
|
public async Task<WebSocket> ConnectTerminalAsync(string sessionId)
|
|
{
|
|
return await Server.CreateWebSocketClient()
|
|
.ConnectAsync(new Uri($"ws://localhost/ws/terminal?sessionId={sessionId}"), CancellationToken.None);
|
|
}
|
|
|
|
public new async ValueTask DisposeAsync()
|
|
{
|
|
await base.DisposeAsync();
|
|
if (Directory.Exists(_dataRoot))
|
|
{
|
|
Directory.Delete(_dataRoot, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingSessionHost : ISessionHost
|
|
{
|
|
private readonly Dictionary<string, int> _startCounts = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, int> _stopCounts = new(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _startedSessions = new(StringComparer.Ordinal);
|
|
|
|
public event EventHandler<TerminalOutputEventArgs>? OutputReceived
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
|
|
public Task StartAsync(string sessionId, CancellationToken cancellationToken)
|
|
{
|
|
if (_startedSessions.Add(sessionId))
|
|
{
|
|
_startCounts[sessionId] = 1;
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync(string sessionId, CancellationToken cancellationToken)
|
|
{
|
|
if (_stopCounts.TryGetValue(sessionId, out var count))
|
|
{
|
|
_stopCounts[sessionId] = count + 1;
|
|
}
|
|
else
|
|
{
|
|
_stopCounts[sessionId] = 1;
|
|
}
|
|
|
|
_startedSessions.Remove(sessionId);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public int StartCountFor(string sessionId)
|
|
{
|
|
return _startCounts.TryGetValue(sessionId, out var count) ? count : 0;
|
|
}
|
|
|
|
public int StopCountFor(string sessionId)
|
|
{
|
|
return _stopCounts.TryGetValue(sessionId, out var count) ? count : 0;
|
|
}
|
|
}
|
|
|
|
private sealed record SessionResponse(
|
|
string SessionId,
|
|
string Name,
|
|
string Status,
|
|
DateTimeOffset CreatedAtUtc,
|
|
DateTimeOffset UpdatedAtUtc);
|
|
|
|
private sealed record TerminalAttachResponse(string SessionId, string Type);
|
|
}
|