javascript-solid-server 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Conditional Request Tests
3
+ *
4
+ * Tests If-Match and If-None-Match header support.
5
+ */
6
+
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import {
10
+ startTestServer,
11
+ stopTestServer,
12
+ request,
13
+ createTestPod,
14
+ assertStatus
15
+ } from './helpers.js';
16
+
17
+ describe('Conditional Requests', () => {
18
+ before(async () => {
19
+ await startTestServer();
20
+ await createTestPod('condtest');
21
+ });
22
+
23
+ after(async () => {
24
+ await stopTestServer();
25
+ });
26
+
27
+ describe('If-None-Match on GET', () => {
28
+ it('should return 304 Not Modified when ETag matches', async () => {
29
+ // Create a resource
30
+ await request('/condtest/public/etag-test.json', {
31
+ method: 'PUT',
32
+ headers: { 'Content-Type': 'application/ld+json' },
33
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/value': 1 }),
34
+ auth: 'condtest'
35
+ });
36
+
37
+ // Get the ETag
38
+ const res1 = await request('/condtest/public/etag-test.json');
39
+ const etag = res1.headers.get('ETag');
40
+ assert.ok(etag, 'Response should have ETag');
41
+
42
+ // Request with matching If-None-Match
43
+ const res2 = await request('/condtest/public/etag-test.json', {
44
+ headers: { 'If-None-Match': etag }
45
+ });
46
+ assertStatus(res2, 304);
47
+ });
48
+
49
+ it('should return 200 when ETag does not match', async () => {
50
+ const res = await request('/condtest/public/etag-test.json', {
51
+ headers: { 'If-None-Match': '"different-etag"' }
52
+ });
53
+ assertStatus(res, 200);
54
+ });
55
+
56
+ it('should return 304 with If-None-Match: *', async () => {
57
+ const res = await request('/condtest/public/etag-test.json', {
58
+ headers: { 'If-None-Match': '*' }
59
+ });
60
+ assertStatus(res, 304);
61
+ });
62
+ });
63
+
64
+ describe('If-Match on PUT', () => {
65
+ it('should succeed when ETag matches', async () => {
66
+ // Create resource
67
+ await request('/condtest/public/match-test.json', {
68
+ method: 'PUT',
69
+ headers: { 'Content-Type': 'application/ld+json' },
70
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 1 }),
71
+ auth: 'condtest'
72
+ });
73
+
74
+ // Get ETag
75
+ const res1 = await request('/condtest/public/match-test.json');
76
+ const etag = res1.headers.get('ETag');
77
+
78
+ // Update with matching If-Match
79
+ const res2 = await request('/condtest/public/match-test.json', {
80
+ method: 'PUT',
81
+ headers: {
82
+ 'Content-Type': 'application/ld+json',
83
+ 'If-Match': etag
84
+ },
85
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 2 }),
86
+ auth: 'condtest'
87
+ });
88
+ assertStatus(res2, 204);
89
+ });
90
+
91
+ it('should return 412 when ETag does not match', async () => {
92
+ const res = await request('/condtest/public/match-test.json', {
93
+ method: 'PUT',
94
+ headers: {
95
+ 'Content-Type': 'application/ld+json',
96
+ 'If-Match': '"wrong-etag"'
97
+ },
98
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 3 }),
99
+ auth: 'condtest'
100
+ });
101
+ assertStatus(res, 412);
102
+ });
103
+
104
+ it('should succeed with If-Match: * on existing resource', async () => {
105
+ const res = await request('/condtest/public/match-test.json', {
106
+ method: 'PUT',
107
+ headers: {
108
+ 'Content-Type': 'application/ld+json',
109
+ 'If-Match': '*'
110
+ },
111
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/v': 4 }),
112
+ auth: 'condtest'
113
+ });
114
+ assertStatus(res, 204);
115
+ });
116
+
117
+ it('should return 412 with If-Match: * on non-existent resource', async () => {
118
+ const res = await request('/condtest/public/nonexistent.json', {
119
+ method: 'PUT',
120
+ headers: {
121
+ 'Content-Type': 'application/ld+json',
122
+ 'If-Match': '*'
123
+ },
124
+ body: JSON.stringify({ '@id': '#new' }),
125
+ auth: 'condtest'
126
+ });
127
+ assertStatus(res, 412);
128
+ });
129
+ });
130
+
131
+ describe('If-None-Match on PUT', () => {
132
+ it('should succeed with If-None-Match: * on new resource', async () => {
133
+ const res = await request('/condtest/public/create-only.json', {
134
+ method: 'PUT',
135
+ headers: {
136
+ 'Content-Type': 'application/ld+json',
137
+ 'If-None-Match': '*'
138
+ },
139
+ body: JSON.stringify({ '@id': '#new' }),
140
+ auth: 'condtest'
141
+ });
142
+ assertStatus(res, 201);
143
+ });
144
+
145
+ it('should return 412 with If-None-Match: * on existing resource', async () => {
146
+ const res = await request('/condtest/public/create-only.json', {
147
+ method: 'PUT',
148
+ headers: {
149
+ 'Content-Type': 'application/ld+json',
150
+ 'If-None-Match': '*'
151
+ },
152
+ body: JSON.stringify({ '@id': '#update' }),
153
+ auth: 'condtest'
154
+ });
155
+ assertStatus(res, 412);
156
+ });
157
+ });
158
+
159
+ describe('If-Match on DELETE', () => {
160
+ it('should succeed when ETag matches', async () => {
161
+ // Create resource
162
+ await request('/condtest/public/delete-match.json', {
163
+ method: 'PUT',
164
+ headers: { 'Content-Type': 'application/ld+json' },
165
+ body: JSON.stringify({ '@id': '#delete' }),
166
+ auth: 'condtest'
167
+ });
168
+
169
+ // Get ETag
170
+ const res1 = await request('/condtest/public/delete-match.json');
171
+ const etag = res1.headers.get('ETag');
172
+
173
+ // Delete with matching If-Match
174
+ const res2 = await request('/condtest/public/delete-match.json', {
175
+ method: 'DELETE',
176
+ headers: { 'If-Match': etag },
177
+ auth: 'condtest'
178
+ });
179
+ assertStatus(res2, 204);
180
+ });
181
+
182
+ it('should return 412 when ETag does not match', async () => {
183
+ // Create resource
184
+ await request('/condtest/public/delete-nomatch.json', {
185
+ method: 'PUT',
186
+ headers: { 'Content-Type': 'application/ld+json' },
187
+ body: JSON.stringify({ '@id': '#delete' }),
188
+ auth: 'condtest'
189
+ });
190
+
191
+ const res = await request('/condtest/public/delete-nomatch.json', {
192
+ method: 'DELETE',
193
+ headers: { 'If-Match': '"wrong-etag"' },
194
+ auth: 'condtest'
195
+ });
196
+ assertStatus(res, 412);
197
+ });
198
+ });
199
+
200
+ describe('If-Match on PATCH', () => {
201
+ it('should succeed when ETag matches', async () => {
202
+ // Create resource
203
+ await request('/condtest/public/patch-match.json', {
204
+ method: 'PUT',
205
+ headers: { 'Content-Type': 'application/ld+json' },
206
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Original' }),
207
+ auth: 'condtest'
208
+ });
209
+
210
+ // Get ETag
211
+ const res1 = await request('/condtest/public/patch-match.json');
212
+ const etag = res1.headers.get('ETag');
213
+
214
+ // Patch with matching If-Match
215
+ const patch = `
216
+ @prefix solid: <http://www.w3.org/ns/solid/terms#>.
217
+ _:patch a solid:InsertDeletePatch;
218
+ solid:inserts { <#test> <http://example.org/updated> "true" }.
219
+ `;
220
+ const res2 = await request('/condtest/public/patch-match.json', {
221
+ method: 'PATCH',
222
+ headers: {
223
+ 'Content-Type': 'text/n3',
224
+ 'If-Match': etag
225
+ },
226
+ body: patch,
227
+ auth: 'condtest'
228
+ });
229
+ assertStatus(res2, 204);
230
+ });
231
+
232
+ it('should return 412 when ETag does not match', async () => {
233
+ const patch = `
234
+ @prefix solid: <http://www.w3.org/ns/solid/terms#>.
235
+ _:patch a solid:InsertDeletePatch;
236
+ solid:inserts { <#test> <http://example.org/bad> "true" }.
237
+ `;
238
+ const res = await request('/condtest/public/patch-match.json', {
239
+ method: 'PATCH',
240
+ headers: {
241
+ 'Content-Type': 'text/n3',
242
+ 'If-Match': '"wrong-etag"'
243
+ },
244
+ body: patch,
245
+ auth: 'condtest'
246
+ });
247
+ assertStatus(res, 412);
248
+ });
249
+ });
250
+ });
@@ -0,0 +1,348 @@
1
+ /**
2
+ * WebSocket Notifications Tests
3
+ *
4
+ * Tests the solid-0.1 WebSocket notification protocol.
5
+ */
6
+
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import { WebSocket } from 'ws';
10
+ import {
11
+ startTestServer,
12
+ stopTestServer,
13
+ request,
14
+ createTestPod,
15
+ getBaseUrl,
16
+ assertStatus,
17
+ assertHeader,
18
+ assertHeaderContains
19
+ } from './helpers.js';
20
+
21
+ describe('WebSocket Notifications (notifications enabled)', () => {
22
+ let wsUrl;
23
+
24
+ before(async () => {
25
+ // Start server with notifications ENABLED
26
+ await startTestServer({ notifications: true });
27
+ await createTestPod('notifytest');
28
+
29
+ // Get WebSocket URL from Updates-Via header
30
+ const res = await request('/notifytest/', { method: 'OPTIONS' });
31
+ wsUrl = res.headers.get('Updates-Via');
32
+ });
33
+
34
+ after(async () => {
35
+ await stopTestServer();
36
+ });
37
+
38
+ describe('Discovery', () => {
39
+ it('should return Updates-Via header in OPTIONS response', async () => {
40
+ const res = await request('/notifytest/public/', { method: 'OPTIONS' });
41
+ assertStatus(res, 204);
42
+ const updatesVia = res.headers.get('Updates-Via');
43
+ assert.ok(updatesVia, 'Should have Updates-Via header');
44
+ assert.ok(updatesVia.startsWith('ws://') || updatesVia.startsWith('wss://'),
45
+ 'Updates-Via should be a WebSocket URL');
46
+ });
47
+
48
+ it('should return Updates-Via header in GET response', async () => {
49
+ const res = await request('/notifytest/');
50
+ assertStatus(res, 200);
51
+ const updatesVia = res.headers.get('Updates-Via');
52
+ assert.ok(updatesVia, 'GET response should have Updates-Via header');
53
+ });
54
+
55
+ it('should expose Updates-Via in CORS headers', async () => {
56
+ const res = await request('/notifytest/', {
57
+ headers: { 'Origin': 'http://example.org' }
58
+ });
59
+ const expose = res.headers.get('Access-Control-Expose-Headers');
60
+ assert.ok(expose && expose.includes('Updates-Via'),
61
+ 'Updates-Via should be in exposed headers');
62
+ });
63
+ });
64
+
65
+ describe('WebSocket Protocol', () => {
66
+ it('should connect and receive protocol greeting', async () => {
67
+ const ws = new WebSocket(wsUrl);
68
+
69
+ const message = await new Promise((resolve, reject) => {
70
+ ws.on('open', () => {});
71
+ ws.on('message', (data) => resolve(data.toString()));
72
+ ws.on('error', reject);
73
+ setTimeout(() => reject(new Error('Timeout')), 5000);
74
+ });
75
+
76
+ assert.strictEqual(message, 'protocol solid-0.1');
77
+ ws.close();
78
+ });
79
+
80
+ it('should acknowledge subscription', async () => {
81
+ const ws = new WebSocket(wsUrl);
82
+ const baseUrl = getBaseUrl();
83
+ const resourceUrl = `${baseUrl}/notifytest/public/test.json`;
84
+
85
+ const messages = [];
86
+
87
+ await new Promise((resolve, reject) => {
88
+ ws.on('open', () => {
89
+ ws.send(`sub ${resourceUrl}`);
90
+ });
91
+ ws.on('message', (data) => {
92
+ messages.push(data.toString());
93
+ if (messages.length >= 2) resolve();
94
+ });
95
+ ws.on('error', reject);
96
+ setTimeout(() => resolve(), 2000);
97
+ });
98
+
99
+ assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
100
+ assert.ok(messages.some(m => m.startsWith('ack ')), 'Should receive ack');
101
+ ws.close();
102
+ });
103
+ });
104
+
105
+ describe('Notifications', () => {
106
+ it('should receive pub notification on PUT', async () => {
107
+ const ws = new WebSocket(wsUrl);
108
+ const baseUrl = getBaseUrl();
109
+ const resourceUrl = `${baseUrl}/notifytest/public/notify-put.json`;
110
+
111
+ const notifications = [];
112
+
113
+ await new Promise((resolve) => {
114
+ ws.on('open', () => {
115
+ ws.send(`sub ${resourceUrl}`);
116
+ });
117
+ ws.on('message', (data) => {
118
+ const msg = data.toString();
119
+ if (msg.startsWith('pub ')) {
120
+ notifications.push(msg);
121
+ resolve();
122
+ }
123
+ });
124
+
125
+ // Wait for subscription to be established
126
+ setTimeout(async () => {
127
+ // Create the resource
128
+ await request('/notifytest/public/notify-put.json', {
129
+ method: 'PUT',
130
+ headers: { 'Content-Type': 'application/ld+json' },
131
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/p': 'value' }),
132
+ auth: 'notifytest'
133
+ });
134
+ }, 500);
135
+
136
+ setTimeout(() => resolve(), 3000);
137
+ });
138
+
139
+ assert.ok(notifications.length > 0, 'Should receive pub notification');
140
+ assert.ok(notifications[0].includes(resourceUrl), 'Notification should include resource URL');
141
+ ws.close();
142
+ });
143
+
144
+ it('should receive pub notification on PATCH', async () => {
145
+ const baseUrl = getBaseUrl();
146
+ const resourceUrl = `${baseUrl}/notifytest/public/notify-patch2.json`;
147
+
148
+ // First create the resource
149
+ await request('/notifytest/public/notify-patch2.json', {
150
+ method: 'PUT',
151
+ headers: { 'Content-Type': 'application/ld+json' },
152
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Original' }),
153
+ auth: 'notifytest'
154
+ });
155
+
156
+ // Create WebSocket AFTER the initial PUT to avoid race condition
157
+ const ws = new WebSocket(wsUrl);
158
+ const notifications = [];
159
+
160
+ await new Promise((resolve) => {
161
+ ws.on('open', () => {
162
+ ws.send(`sub ${resourceUrl}`);
163
+ });
164
+ ws.on('message', (data) => {
165
+ const msg = data.toString();
166
+ if (msg.startsWith('pub ')) {
167
+ notifications.push(msg);
168
+ resolve();
169
+ }
170
+ });
171
+
172
+ // Wait for subscription to be established, then patch
173
+ setTimeout(async () => {
174
+ const patch = `
175
+ @prefix solid: <http://www.w3.org/ns/solid/terms#>.
176
+ _:patch a solid:InsertDeletePatch;
177
+ solid:inserts { <#test> <http://example.org/name> "Updated" }.
178
+ `;
179
+ await request('/notifytest/public/notify-patch2.json', {
180
+ method: 'PATCH',
181
+ headers: { 'Content-Type': 'text/n3' },
182
+ body: patch,
183
+ auth: 'notifytest'
184
+ });
185
+ }, 500);
186
+
187
+ setTimeout(() => resolve(), 3000);
188
+ });
189
+
190
+ assert.ok(notifications.length > 0, 'Should receive pub notification for PATCH');
191
+ ws.close();
192
+ });
193
+
194
+ it('should receive pub notification on DELETE', async () => {
195
+ const baseUrl = getBaseUrl();
196
+ const resourceUrl = `${baseUrl}/notifytest/public/notify-delete2.json`;
197
+
198
+ // First create the resource
199
+ await request('/notifytest/public/notify-delete2.json', {
200
+ method: 'PUT',
201
+ headers: { 'Content-Type': 'application/ld+json' },
202
+ body: JSON.stringify({ '@id': '#test' }),
203
+ auth: 'notifytest'
204
+ });
205
+
206
+ // Create WebSocket AFTER the initial PUT to avoid race condition
207
+ const ws = new WebSocket(wsUrl);
208
+ const notifications = [];
209
+
210
+ await new Promise((resolve) => {
211
+ ws.on('open', () => {
212
+ ws.send(`sub ${resourceUrl}`);
213
+ });
214
+ ws.on('message', (data) => {
215
+ const msg = data.toString();
216
+ if (msg.startsWith('pub ')) {
217
+ notifications.push(msg);
218
+ resolve();
219
+ }
220
+ });
221
+
222
+ // Wait for subscription, then delete
223
+ setTimeout(async () => {
224
+ await request('/notifytest/public/notify-delete2.json', {
225
+ method: 'DELETE',
226
+ auth: 'notifytest'
227
+ });
228
+ }, 500);
229
+
230
+ setTimeout(() => resolve(), 3000);
231
+ });
232
+
233
+ assert.ok(notifications.length > 0, 'Should receive pub notification for DELETE');
234
+ ws.close();
235
+ });
236
+
237
+ it('should receive container notification when child changes', async () => {
238
+ const ws = new WebSocket(wsUrl);
239
+ const baseUrl = getBaseUrl();
240
+ const containerUrl = `${baseUrl}/notifytest/public/`;
241
+
242
+ const notifications = [];
243
+
244
+ await new Promise((resolve) => {
245
+ ws.on('open', () => {
246
+ // Subscribe to container
247
+ ws.send(`sub ${containerUrl}`);
248
+ });
249
+ ws.on('message', (data) => {
250
+ const msg = data.toString();
251
+ if (msg.startsWith('pub ')) {
252
+ notifications.push(msg);
253
+ resolve();
254
+ }
255
+ });
256
+
257
+ // Wait for subscription, then create a child resource
258
+ setTimeout(async () => {
259
+ await request('/notifytest/public/child-resource.json', {
260
+ method: 'PUT',
261
+ headers: { 'Content-Type': 'application/ld+json' },
262
+ body: JSON.stringify({ '@id': '#child' }),
263
+ auth: 'notifytest'
264
+ });
265
+ }, 500);
266
+
267
+ setTimeout(() => resolve(), 3000);
268
+ });
269
+
270
+ assert.ok(notifications.length > 0, 'Container should receive notification for child changes');
271
+ ws.close();
272
+ });
273
+ });
274
+
275
+ describe('Multiple Subscribers', () => {
276
+ it('should notify all subscribers', async () => {
277
+ const ws1 = new WebSocket(wsUrl);
278
+ const ws2 = new WebSocket(wsUrl);
279
+ const baseUrl = getBaseUrl();
280
+ const resourceUrl = `${baseUrl}/notifytest/public/multi-sub.json`;
281
+
282
+ const notifications1 = [];
283
+ const notifications2 = [];
284
+
285
+ await new Promise((resolve) => {
286
+ let ready = 0;
287
+
288
+ const setupWs = (ws, notifications) => {
289
+ ws.on('open', () => {
290
+ ws.send(`sub ${resourceUrl}`);
291
+ });
292
+ ws.on('message', (data) => {
293
+ const msg = data.toString();
294
+ if (msg.startsWith('ack ')) {
295
+ ready++;
296
+ if (ready === 2) {
297
+ // Both subscribed, trigger change
298
+ setTimeout(async () => {
299
+ await request('/notifytest/public/multi-sub.json', {
300
+ method: 'PUT',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ test: true }),
303
+ auth: 'notifytest'
304
+ });
305
+ }, 100);
306
+ }
307
+ }
308
+ if (msg.startsWith('pub ')) {
309
+ notifications.push(msg);
310
+ if (notifications1.length > 0 && notifications2.length > 0) {
311
+ resolve();
312
+ }
313
+ }
314
+ });
315
+ };
316
+
317
+ setupWs(ws1, notifications1);
318
+ setupWs(ws2, notifications2);
319
+
320
+ setTimeout(() => resolve(), 4000);
321
+ });
322
+
323
+ assert.ok(notifications1.length > 0, 'First subscriber should receive notification');
324
+ assert.ok(notifications2.length > 0, 'Second subscriber should receive notification');
325
+ ws1.close();
326
+ ws2.close();
327
+ });
328
+ });
329
+ });
330
+
331
+ describe('WebSocket Notifications (notifications disabled - default)', () => {
332
+ before(async () => {
333
+ // Start server with notifications DISABLED (default)
334
+ await startTestServer({ notifications: false });
335
+ await createTestPod('nonotify');
336
+ });
337
+
338
+ after(async () => {
339
+ await stopTestServer();
340
+ });
341
+
342
+ it('should NOT return Updates-Via header when notifications disabled', async () => {
343
+ const res = await request('/nonotify/', { method: 'OPTIONS' });
344
+ assertStatus(res, 204);
345
+ const updatesVia = res.headers.get('Updates-Via');
346
+ assert.strictEqual(updatesVia, null, 'Should NOT have Updates-Via header when disabled');
347
+ });
348
+ });