Skip to content

Commit 937ac4e

Browse files
PureWeenCopilot
andcommitted
fix: address PR #322 review comments
ApprovePairRequestAsync TCS fix: - Move tcs.TrySetResult(true) inside the try block; call tcs.TrySetResult(false) in the catch block. Previously the TCS was always resolved true even when SendAsync threw, causing a silent discrepancy between host ('failed') and worker ('approved'). NIC scoring RFC-1918 172.16.0.0/12: - Add IsRfc1918_172() helper to check the 172.16-31 octet range. - Update ScoreNetworkInterface() to treat 172.16-31 IPs as private LAN (previously only 192.168.x and 10.x were recognized). - Docker bridge IPs (172.17.x, 172.18.x) are still filtered by name-pattern and IsVirtualAdapterIp(), so they are excluded before scoring. RequestPairAsync null BridgeUrl guard: - Add explicit check: if resp.BridgeUrl or resp.Token is null/empty, return PairRequestResult.Unreachable instead of silently calling LinkWorker with null args (which silently no-ops but still returns Approved to the caller). Tests (FiestaPairingTests.cs): - ParseAndLinkPairingString_Roundtrip: manually encode pp+ string, call ParseAndLinkPairingString, verify Url/Token/Hostname on linked worker. - ParseAndLinkPairingString_InvalidPrefix / MissingUrl: FormatException cases. - ApprovePairRequestAsync_SendFails_TcsResultIsFalse: inject a FaultyOpenWebSocket (State=Open but SendAsync throws) via reflection; verify TCS resolves false. - RequestPairAsync_ApprovedWithNullBridgeUrl_ReturnsUnreachable: real HttpListener server returns Approved+null BridgeUrl; verify Unreachable + no worker linked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 61e56c4 commit 937ac4e

2 files changed

Lines changed: 263 additions & 4 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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+
}

PolyPilot/Services/FiestaService.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -486,13 +486,13 @@ await SendAsync(pending.Socket, BridgeMessage.Create(
486486
Token = token,
487487
WorkerName = Environment.MachineName
488488
}), CancellationToken.None);
489+
tcs.TrySetResult(true);
489490
}
490491
catch (Exception ex)
491492
{
492493
Console.WriteLine($"[Fiesta] Failed to send pair approval: {ex.Message}");
494+
tcs.TrySetResult(false);
493495
}
494-
495-
tcs.TrySetResult(true);
496496
}
497497

498498
public void DenyPairRequest(string requestId)
@@ -556,8 +556,12 @@ await SendAsync(ws, BridgeMessage.Create(
556556
if (resp == null || !resp.Approved)
557557
return PairRequestResult.Denied;
558558

559+
// Guard: an approval without connection details is a malformed response
560+
if (string.IsNullOrWhiteSpace(resp.BridgeUrl) || string.IsNullOrWhiteSpace(resp.Token))
561+
return PairRequestResult.Unreachable;
562+
559563
var workerName = !string.IsNullOrWhiteSpace(resp.WorkerName) ? resp.WorkerName : worker.Hostname;
560-
LinkWorker(workerName, worker.Hostname, resp.BridgeUrl!, resp.Token!);
564+
LinkWorker(workerName, worker.Hostname, resp.BridgeUrl, resp.Token);
561565
return PairRequestResult.Approved;
562566
}
563567
catch (WebSocketException) { return PairRequestResult.Unreachable; }
@@ -1124,12 +1128,20 @@ private static bool IsVirtualAdapterName(string name) =>
11241128
private static bool IsVirtualAdapterIp(string ip) =>
11251129
ip.StartsWith("172.17.", StringComparison.Ordinal) || // Docker default bridge
11261130
ip.StartsWith("172.18.", StringComparison.Ordinal); // Docker custom networks
1131+
// Note: 172.16-31 is RFC-1918 but IsRfc1918_172 distinguishes real from Docker in scoring
1132+
1133+
private static bool IsRfc1918_172(string ip)
1134+
{
1135+
var parts = ip.Split('.');
1136+
return parts.Length >= 2 && int.TryParse(parts[1], out var oct) && oct >= 16 && oct <= 31;
1137+
}
11271138

11281139
private static int ScoreNetworkInterface(NetworkInterfaceType type, string ip)
11291140
{
11301141
// Prefer RFC-1918 private ranges (real LAN) vs others
11311142
bool isPrivateLan = ip.StartsWith("192.168.", StringComparison.Ordinal)
1132-
|| ip.StartsWith("10.", StringComparison.Ordinal);
1143+
|| ip.StartsWith("10.", StringComparison.Ordinal)
1144+
|| (ip.StartsWith("172.", StringComparison.Ordinal) && IsRfc1918_172(ip));
11331145

11341146
return type switch
11351147
{

0 commit comments

Comments
 (0)