|
| 1 | +using System.Net; |
| 2 | +using System.Net.WebSockets; |
| 3 | +using System.Reflection; |
| 4 | +using System.Text; |
| 5 | +using System.Text.Json; |
| 6 | +using Microsoft.Extensions.DependencyInjection; |
| 7 | +using PolyPilot.Models; |
| 8 | +using PolyPilot.Services; |
| 9 | + |
| 10 | +namespace PolyPilot.Tests; |
| 11 | + |
| 12 | +/// <summary> |
| 13 | +/// Tests for Fiesta pairing features: pairing string encode/decode, |
| 14 | +/// ApprovePairRequestAsync TCS behavior on failure, and RequestPairAsync |
| 15 | +/// with a malformed approval response (Approved=true but null BridgeUrl). |
| 16 | +/// </summary> |
| 17 | +public class FiestaPairingTests : IDisposable |
| 18 | +{ |
| 19 | + private readonly WsBridgeServer _bridgeServer; |
| 20 | + private readonly CopilotService _copilot; |
| 21 | + private readonly FiestaService _fiesta; |
| 22 | + |
| 23 | + public FiestaPairingTests() |
| 24 | + { |
| 25 | + _bridgeServer = new WsBridgeServer(); |
| 26 | + _copilot = new CopilotService( |
| 27 | + new StubChatDatabase(), |
| 28 | + new StubServerManager(), |
| 29 | + new StubWsBridgeClient(), |
| 30 | + new RepoManager(), |
| 31 | + new ServiceCollection().BuildServiceProvider(), |
| 32 | + new StubDemoService()); |
| 33 | + _fiesta = new FiestaService(_copilot, _bridgeServer); |
| 34 | + } |
| 35 | + |
| 36 | + public void Dispose() |
| 37 | + { |
| 38 | + _fiesta.Dispose(); |
| 39 | + _bridgeServer.Dispose(); |
| 40 | + } |
| 41 | + |
| 42 | + // ---- Helpers ---- |
| 43 | + |
| 44 | + private static string BuildPairingString(string url, string token, string hostname) |
| 45 | + { |
| 46 | + var payload = new FiestaPairingPayload { Url = url, Token = token, Hostname = hostname }; |
| 47 | + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); |
| 48 | + var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)) |
| 49 | + .TrimEnd('=') |
| 50 | + .Replace('+', '-') |
| 51 | + .Replace('/', '_'); |
| 52 | + return $"pp+{b64}"; |
| 53 | + } |
| 54 | + |
| 55 | + private static int GetFreePort() |
| 56 | + { |
| 57 | + using var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); |
| 58 | + l.Start(); |
| 59 | + var port = ((IPEndPoint)l.LocalEndpoint).Port; |
| 60 | + l.Stop(); |
| 61 | + return port; |
| 62 | + } |
| 63 | + |
| 64 | + // ---- Test 1: Pairing string roundtrip ---- |
| 65 | + |
| 66 | + [Fact] |
| 67 | + public void ParseAndLinkPairingString_Roundtrip_CorrectWorkerFields() |
| 68 | + { |
| 69 | + const string url = "http://192.168.1.50:4322"; |
| 70 | + const string token = "test-token-abc123"; |
| 71 | + const string hostname = "devbox-1"; |
| 72 | + |
| 73 | + var pairingString = BuildPairingString(url, token, hostname); |
| 74 | + Assert.StartsWith("pp+", pairingString); |
| 75 | + |
| 76 | + var linked = _fiesta.ParseAndLinkPairingString(pairingString); |
| 77 | + |
| 78 | + Assert.Equal(url, linked.BridgeUrl); |
| 79 | + Assert.Equal(token, linked.Token); |
| 80 | + Assert.Equal(hostname, linked.Name); |
| 81 | + Assert.Single(_fiesta.LinkedWorkers); |
| 82 | + } |
| 83 | + |
| 84 | + [Fact] |
| 85 | + public void ParseAndLinkPairingString_InvalidPrefix_ThrowsFormatException() |
| 86 | + { |
| 87 | + Assert.Throws<FormatException>(() => _fiesta.ParseAndLinkPairingString("notvalid")); |
| 88 | + Assert.Throws<FormatException>(() => _fiesta.ParseAndLinkPairingString("pp+!!!notbase64!!!")); |
| 89 | + } |
| 90 | + |
| 91 | + [Fact] |
| 92 | + public void ParseAndLinkPairingString_MissingUrl_ThrowsFormatException() |
| 93 | + { |
| 94 | + // Build a pairing string that's valid base64 but has no URL field |
| 95 | + var payload = new FiestaPairingPayload { Url = "", Token = "tok", Hostname = "host" }; |
| 96 | + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); |
| 97 | + var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); |
| 98 | + var s = $"pp+{b64}"; |
| 99 | + |
| 100 | + Assert.Throws<FormatException>(() => _fiesta.ParseAndLinkPairingString(s)); |
| 101 | + } |
| 102 | + |
| 103 | + // ---- Test 2: ApprovePairRequestAsync TCS result on SendAsync failure ---- |
| 104 | + |
| 105 | + [Fact] |
| 106 | + public async Task ApprovePairRequestAsync_SendFails_TcsResultIsFalse() |
| 107 | + { |
| 108 | + const string requestId = "req-test-001"; |
| 109 | + var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); |
| 110 | + |
| 111 | + // Inject a pending pair request with a WebSocket that reports Open state |
| 112 | + // but throws on SendAsync, simulating a race-condition socket failure. |
| 113 | + var faultySocket = new FaultyOpenWebSocket(); |
| 114 | + var pending = new PendingPairRequest |
| 115 | + { |
| 116 | + RequestId = requestId, |
| 117 | + HostName = "test-host", |
| 118 | + HostInstanceId = "host-id", |
| 119 | + RemoteIp = "127.0.0.1", |
| 120 | + Socket = faultySocket, |
| 121 | + CompletionSource = tcs, |
| 122 | + ExpiresAt = DateTime.UtcNow.AddSeconds(60) |
| 123 | + }; |
| 124 | + |
| 125 | + var dictField = typeof(FiestaService).GetField("_pendingPairRequests", BindingFlags.NonPublic | BindingFlags.Instance)!; |
| 126 | + var dict = (Dictionary<string, PendingPairRequest>)dictField.GetValue(_fiesta)!; |
| 127 | + lock (dict) dict[requestId] = pending; |
| 128 | + |
| 129 | + await _fiesta.ApprovePairRequestAsync(requestId); |
| 130 | + |
| 131 | + // The TCS should be resolved false because SendAsync threw |
| 132 | + Assert.True(tcs.Task.IsCompleted); |
| 133 | + Assert.False(await tcs.Task); |
| 134 | + } |
| 135 | + |
| 136 | + [Fact] |
| 137 | + public async Task ApprovePairRequestAsync_UnknownRequestId_DoesNotThrow() |
| 138 | + { |
| 139 | + // Should silently return if the request id is not found |
| 140 | + await _fiesta.ApprovePairRequestAsync("nonexistent-id"); |
| 141 | + } |
| 142 | + |
| 143 | + // ---- Test 3: RequestPairAsync with Approved=true but null BridgeUrl ---- |
| 144 | + |
| 145 | + [Fact] |
| 146 | + public async Task RequestPairAsync_ApprovedWithNullBridgeUrl_ReturnsUnreachable() |
| 147 | + { |
| 148 | + var port = GetFreePort(); |
| 149 | + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); |
| 150 | + |
| 151 | + // Stand up a minimal WebSocket server that responds with Approved=true but no BridgeUrl |
| 152 | + var serverTask = Task.Run(async () => |
| 153 | + { |
| 154 | + var listener = new HttpListener(); |
| 155 | + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); |
| 156 | + listener.Start(); |
| 157 | + try |
| 158 | + { |
| 159 | + var ctx = await listener.GetContextAsync().WaitAsync(cts.Token); |
| 160 | + if (!ctx.Request.IsWebSocketRequest) { ctx.Response.StatusCode = 400; ctx.Response.Close(); return; } |
| 161 | + |
| 162 | + var wsCtx = await ctx.AcceptWebSocketAsync(subProtocol: null); |
| 163 | + var ws = wsCtx.WebSocket; |
| 164 | + |
| 165 | + // Read (and discard) the pair request |
| 166 | + var buf = new byte[4096]; |
| 167 | + await ws.ReceiveAsync(new ArraySegment<byte>(buf), cts.Token); |
| 168 | + |
| 169 | + // Send back Approved=true with no BridgeUrl / Token |
| 170 | + var response = BridgeMessage.Create(BridgeMessageTypes.FiestaPairResponse, |
| 171 | + new FiestaPairResponsePayload |
| 172 | + { |
| 173 | + RequestId = "req-null-url", |
| 174 | + Approved = true, |
| 175 | + BridgeUrl = null, |
| 176 | + Token = null, |
| 177 | + WorkerName = "worker" |
| 178 | + }); |
| 179 | + var bytes = Encoding.UTF8.GetBytes(response.Serialize()); |
| 180 | + await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, cts.Token); |
| 181 | + |
| 182 | + // Best-effort close; client may have already closed |
| 183 | + try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", cts.Token); } catch { } |
| 184 | + } |
| 185 | + catch (OperationCanceledException) { /* test timed out */ } |
| 186 | + catch (Exception) { /* ignore server-side cleanup errors */ } |
| 187 | + finally |
| 188 | + { |
| 189 | + listener.Stop(); |
| 190 | + } |
| 191 | + }, cts.Token); |
| 192 | + |
| 193 | + // Give the server a moment to bind |
| 194 | + await Task.Delay(50, cts.Token); |
| 195 | + |
| 196 | + var worker = new FiestaDiscoveredWorker |
| 197 | + { |
| 198 | + InstanceId = "remote-id", |
| 199 | + Hostname = "remote-box", |
| 200 | + BridgeUrl = $"http://127.0.0.1:{port}" |
| 201 | + }; |
| 202 | + |
| 203 | + var countBefore = _fiesta.LinkedWorkers.Count; |
| 204 | + var result = await _fiesta.RequestPairAsync(worker, cts.Token); |
| 205 | + |
| 206 | + // An approved response with no BridgeUrl should be treated as Unreachable |
| 207 | + Assert.Equal(PairRequestResult.Unreachable, result); |
| 208 | + |
| 209 | + // No new worker should have been linked by this call |
| 210 | + Assert.Equal(countBefore, _fiesta.LinkedWorkers.Count); |
| 211 | + Assert.DoesNotContain(_fiesta.LinkedWorkers, w => |
| 212 | + string.Equals(w.Hostname, "remote-box", StringComparison.OrdinalIgnoreCase) || |
| 213 | + w.BridgeUrl.Contains($"127.0.0.1:{port}")); |
| 214 | + |
| 215 | + await serverTask; |
| 216 | + } |
| 217 | + |
| 218 | + // ---- Helpers: fake WebSocket implementations ---- |
| 219 | + |
| 220 | + /// <summary> |
| 221 | + /// A WebSocket that passes the State == Open guard but throws on SendAsync, |
| 222 | + /// simulating a socket that closes between the state check and the write. |
| 223 | + /// </summary> |
| 224 | + private sealed class FaultyOpenWebSocket : WebSocket |
| 225 | + { |
| 226 | + public override WebSocketState State => WebSocketState.Open; |
| 227 | + public override WebSocketCloseStatus? CloseStatus => null; |
| 228 | + public override string? CloseStatusDescription => null; |
| 229 | + public override string? SubProtocol => null; |
| 230 | + |
| 231 | + public override void Abort() { } |
| 232 | + |
| 233 | + public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken ct) |
| 234 | + => Task.CompletedTask; |
| 235 | + |
| 236 | + public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken ct) |
| 237 | + => Task.CompletedTask; |
| 238 | + |
| 239 | + public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken ct) |
| 240 | + => Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true)); |
| 241 | + |
| 242 | + public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken ct) |
| 243 | + => throw new WebSocketException("Simulated send failure after state check"); |
| 244 | + |
| 245 | + public override void Dispose() { } |
| 246 | + } |
| 247 | +} |
0 commit comments