Skip to content

Commit fa76cc4

Browse files
test: add WebRTC signaling tests and fix reconnection race
- 6 tests covering auth, peer presence, full signaling relay, error handling - Skip WAC and dotfile checks for /.webrtc path - Fix reconnection race: old socket close handler no longer deletes the replacement socket from the peers map
1 parent 325d69a commit fa76cc4

3 files changed

Lines changed: 212 additions & 5 deletions

File tree

src/server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ export function createServer(options = {}) {
328328
// Security: Block access to dotfiles except allowed Solid-specific ones
329329
// This prevents exposure of .git/, .env, .htpasswd, etc.
330330
// Git protocol requests bypass this check when git is enabled
331-
const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account'];
331+
const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account', '.webrtc'];
332332
fastify.addHook('onRequest', async (request, reply) => {
333333
// Allow git protocol requests through when git is enabled
334334
if (gitEnabled && isGitRequest(request.url)) {
@@ -414,6 +414,7 @@ export function createServer(options = {}) {
414414
request.url.startsWith('/storage/') ||
415415
(payEnabled && isPayRequest(request.url)) ||
416416
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
417+
(webrtcEnabled && request.url.startsWith(webrtcPath)) ||
417418
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
418419
return;
419420
}

src/webrtc/index.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ export async function webrtcPlugin(fastify, options = {}) {
5050
return;
5151
}
5252

53-
// Register this peer
53+
// Register this peer (close old connection if reconnecting)
5454
const existing = peers.get(webId);
5555
if (existing) {
56+
peers.delete(webId);
5657
existing.close();
5758
}
5859
peers.set(webId, socket);
@@ -95,12 +96,17 @@ export async function webrtcPlugin(fastify, options = {}) {
9596
});
9697

9798
socket.on('close', () => {
98-
peers.delete(webId);
99-
broadcast(webId, { type: 'peer-left', webId });
99+
// Only remove if this socket is still the registered one (not replaced by reconnect)
100+
if (peers.get(webId) === socket) {
101+
peers.delete(webId);
102+
broadcast(webId, { type: 'peer-left', webId });
103+
}
100104
});
101105

102106
socket.on('error', () => {
103-
peers.delete(webId);
107+
if (peers.get(webId) === socket) {
108+
peers.delete(webId);
109+
}
104110
});
105111
});
106112
}

test/webrtc.test.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* WebRTC Signaling Server Tests
3+
*/
4+
5+
import { describe, it, before, after } from 'node:test';
6+
import assert from 'node:assert';
7+
import { WebSocket } from 'ws';
8+
import {
9+
startTestServer,
10+
stopTestServer,
11+
createTestPod,
12+
getBaseUrl,
13+
getPodToken
14+
} from './helpers.js';
15+
16+
describe('WebRTC Signaling', () => {
17+
let wsUrl;
18+
19+
before(async () => {
20+
await startTestServer({ webrtc: true });
21+
await createTestPod('alice');
22+
await createTestPod('bob');
23+
const base = getBaseUrl();
24+
wsUrl = base.replace('http', 'ws') + '/.webrtc';
25+
});
26+
27+
after(async () => {
28+
await stopTestServer();
29+
});
30+
31+
/** Connect an authenticated WebSocket for a pod user, waits for open */
32+
function connectPeer(podName) {
33+
const token = getPodToken(podName);
34+
const ws = new WebSocket(wsUrl, {
35+
headers: { 'Authorization': `Bearer ${token}` }
36+
});
37+
return ws;
38+
}
39+
40+
/** Connect and wait for the 'peers' welcome message */
41+
async function connectAndWait(podName) {
42+
const ws = connectPeer(podName);
43+
const msg = await waitForMessage(ws, 'peers');
44+
return { ws, ...msg };
45+
}
46+
47+
/** Wait for a specific message type from a WebSocket */
48+
function waitForMessage(ws, type, timeout = 3000) {
49+
return new Promise((resolve, reject) => {
50+
const timer = setTimeout(() => reject(new Error(`Timeout waiting for "${type}"`)), timeout);
51+
ws.on('message', function handler(data) {
52+
const msg = JSON.parse(data.toString());
53+
if (msg.type === type) {
54+
clearTimeout(timer);
55+
ws.removeListener('message', handler);
56+
resolve(msg);
57+
}
58+
});
59+
});
60+
}
61+
62+
/** Collect messages from a WebSocket for a duration */
63+
function collectMessages(ws, duration = 500) {
64+
return new Promise((resolve) => {
65+
const msgs = [];
66+
const handler = (data) => msgs.push(JSON.parse(data.toString()));
67+
ws.on('message', handler);
68+
setTimeout(() => {
69+
ws.removeListener('message', handler);
70+
resolve(msgs);
71+
}, duration);
72+
});
73+
}
74+
75+
describe('Authentication', () => {
76+
it('should reject unauthenticated connections', async () => {
77+
const ws = new WebSocket(wsUrl);
78+
79+
const msg = await waitForMessage(ws, 'error');
80+
assert.strictEqual(msg.type, 'error');
81+
assert.ok(msg.message.includes('Authentication'));
82+
ws.close();
83+
});
84+
85+
it('should accept authenticated connections', async () => {
86+
const ws = connectPeer('alice');
87+
88+
const msg = await waitForMessage(ws, 'peers');
89+
assert.strictEqual(msg.type, 'peers');
90+
assert.ok(msg.you, 'Should include own WebID');
91+
assert.ok(Array.isArray(msg.peers), 'Should include peers list');
92+
ws.close();
93+
});
94+
});
95+
96+
describe('Peer Presence and Signaling Relay', () => {
97+
it('should handle full signaling lifecycle', async () => {
98+
// Alice connects first — should see no peers
99+
const { ws: alice, you: aliceId } = await connectAndWait('alice');
100+
101+
// Bob joins — set up listener for peer-joined before bob connects
102+
const joinPromise = waitForMessage(alice, 'peer-joined');
103+
const { ws: bob, you: bobId, peers: bobPeerList } = await connectAndWait('bob');
104+
105+
// Bob should see alice in the peer list
106+
assert.strictEqual(bobPeerList.length, 1, 'Bob should see Alice');
107+
108+
// Alice should get peer-joined notification
109+
const joinMsg = await joinPromise;
110+
assert.strictEqual(joinMsg.type, 'peer-joined');
111+
112+
// 1. Alice sends offer to Bob
113+
const offerPromise = waitForMessage(bob, 'offer');
114+
alice.send(JSON.stringify({ type: 'offer', to: bobId, sdp: 'v=0\r\n' }));
115+
116+
const offer = await offerPromise;
117+
assert.strictEqual(offer.type, 'offer');
118+
assert.strictEqual(offer.from, aliceId);
119+
assert.ok(offer.sdp, 'Should include SDP');
120+
assert.strictEqual(offer.to, undefined, 'Should strip "to" field');
121+
122+
// 2. Bob sends answer to Alice
123+
const answerPromise = waitForMessage(alice, 'answer');
124+
bob.send(JSON.stringify({ type: 'answer', to: aliceId, sdp: 'v=0\r\n' }));
125+
126+
const answer = await answerPromise;
127+
assert.strictEqual(answer.type, 'answer');
128+
assert.strictEqual(answer.from, bobId);
129+
130+
// 3. Alice sends ICE candidate to Bob
131+
const candidatePromise = waitForMessage(bob, 'candidate');
132+
alice.send(JSON.stringify({
133+
type: 'candidate', to: bobId,
134+
candidate: { candidate: 'candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host', sdpMid: '0' }
135+
}));
136+
137+
const candidate = await candidatePromise;
138+
assert.strictEqual(candidate.type, 'candidate');
139+
assert.ok(candidate.candidate.candidate);
140+
141+
// 4. Alice sends hangup to Bob
142+
const hangupPromise = waitForMessage(bob, 'hangup');
143+
alice.send(JSON.stringify({ type: 'hangup', to: bobId }));
144+
145+
const hangup = await hangupPromise;
146+
assert.strictEqual(hangup.type, 'hangup');
147+
assert.strictEqual(hangup.from, aliceId);
148+
149+
// 5. Bob leaves — alice should get notified
150+
const leavePromise = waitForMessage(alice, 'peer-left');
151+
bob.close();
152+
153+
const leaveMsg = await leavePromise;
154+
assert.strictEqual(leaveMsg.type, 'peer-left');
155+
156+
alice.close();
157+
await new Promise(r => setTimeout(r, 100));
158+
});
159+
});
160+
161+
describe('Error Handling', () => {
162+
it('should reject invalid JSON', async () => {
163+
const alice = connectPeer('alice');
164+
await waitForMessage(alice, 'peers');
165+
166+
alice.send('not json');
167+
const err = await waitForMessage(alice, 'error');
168+
assert.strictEqual(err.message, 'Invalid JSON');
169+
170+
alice.close();
171+
});
172+
173+
it('should reject messages without "to" field', async () => {
174+
const alice = connectPeer('alice');
175+
await waitForMessage(alice, 'peers');
176+
177+
alice.send(JSON.stringify({ type: 'offer', sdp: '...' }));
178+
const err = await waitForMessage(alice, 'error');
179+
assert.ok(err.message.includes('Missing'));
180+
181+
alice.close();
182+
});
183+
184+
it('should error when target peer is not online', async () => {
185+
const alice = connectPeer('alice');
186+
await waitForMessage(alice, 'peers');
187+
188+
alice.send(JSON.stringify({
189+
type: 'offer',
190+
to: 'https://nobody.example/profile/card#me',
191+
sdp: '...'
192+
}));
193+
const err = await waitForMessage(alice, 'error');
194+
assert.ok(err.message.includes('not online'));
195+
196+
alice.close();
197+
await new Promise(r => setTimeout(r, 50));
198+
});
199+
});
200+
});

0 commit comments

Comments
 (0)