javascript-solid-server 0.0.9 → 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,153 @@
1
+ /**
2
+ * Conditional Request Utilities
3
+ *
4
+ * Implements HTTP conditional request headers:
5
+ * - If-Match: Proceed only if ETag matches (for safe updates)
6
+ * - If-None-Match: Proceed only if ETag doesn't match (for caching/create-only)
7
+ */
8
+
9
+ /**
10
+ * Normalize an ETag value (remove weak prefix and quotes)
11
+ * @param {string} etag
12
+ * @returns {string}
13
+ */
14
+ function normalizeEtag(etag) {
15
+ if (!etag) return '';
16
+ // Remove weak prefix W/
17
+ let normalized = etag.replace(/^W\//, '');
18
+ // Remove surrounding quotes
19
+ normalized = normalized.replace(/^"(.*)"$/, '$1');
20
+ return normalized;
21
+ }
22
+
23
+ /**
24
+ * Parse an If-Match or If-None-Match header value
25
+ * @param {string} headerValue
26
+ * @returns {string[]} Array of ETags, or ['*'] for wildcard
27
+ */
28
+ function parseEtagHeader(headerValue) {
29
+ if (!headerValue) return [];
30
+ if (headerValue.trim() === '*') return ['*'];
31
+
32
+ // Split by comma and normalize each ETag
33
+ return headerValue.split(',').map(etag => normalizeEtag(etag.trim()));
34
+ }
35
+
36
+ /**
37
+ * Check If-Match header
38
+ * Returns true if the request should proceed, false if it should be rejected (412)
39
+ *
40
+ * @param {string} ifMatchHeader - The If-Match header value
41
+ * @param {string|null} currentEtag - Current ETag of the resource (null if doesn't exist)
42
+ * @returns {{ ok: boolean, status?: number, error?: string }}
43
+ */
44
+ export function checkIfMatch(ifMatchHeader, currentEtag) {
45
+ if (!ifMatchHeader) {
46
+ return { ok: true }; // No If-Match header, proceed
47
+ }
48
+
49
+ const etags = parseEtagHeader(ifMatchHeader);
50
+
51
+ // If resource doesn't exist, If-Match always fails
52
+ if (currentEtag === null) {
53
+ return {
54
+ ok: false,
55
+ status: 412,
56
+ error: 'Precondition Failed: Resource does not exist'
57
+ };
58
+ }
59
+
60
+ // Wildcard matches any existing resource
61
+ if (etags.includes('*')) {
62
+ return { ok: true };
63
+ }
64
+
65
+ // Check if any ETag matches
66
+ const normalizedCurrent = normalizeEtag(currentEtag);
67
+ const matches = etags.some(etag => etag === normalizedCurrent);
68
+
69
+ if (!matches) {
70
+ return {
71
+ ok: false,
72
+ status: 412,
73
+ error: 'Precondition Failed: ETag mismatch'
74
+ };
75
+ }
76
+
77
+ return { ok: true };
78
+ }
79
+
80
+ /**
81
+ * Check If-None-Match header for GET/HEAD (caching)
82
+ * Returns true if the request should proceed, false if 304 Not Modified
83
+ *
84
+ * @param {string} ifNoneMatchHeader - The If-None-Match header value
85
+ * @param {string|null} currentEtag - Current ETag of the resource
86
+ * @returns {{ ok: boolean, notModified?: boolean }}
87
+ */
88
+ export function checkIfNoneMatchForGet(ifNoneMatchHeader, currentEtag) {
89
+ if (!ifNoneMatchHeader || currentEtag === null) {
90
+ return { ok: true }; // No header or no resource, proceed
91
+ }
92
+
93
+ const etags = parseEtagHeader(ifNoneMatchHeader);
94
+
95
+ // Wildcard matches any existing resource
96
+ if (etags.includes('*')) {
97
+ return { ok: false, notModified: true };
98
+ }
99
+
100
+ // Check if any ETag matches
101
+ const normalizedCurrent = normalizeEtag(currentEtag);
102
+ const matches = etags.some(etag => etag === normalizedCurrent);
103
+
104
+ if (matches) {
105
+ return { ok: false, notModified: true };
106
+ }
107
+
108
+ return { ok: true };
109
+ }
110
+
111
+ /**
112
+ * Check If-None-Match header for PUT/POST (create-only semantics)
113
+ * Returns true if the request should proceed, false if 412 Precondition Failed
114
+ *
115
+ * @param {string} ifNoneMatchHeader - The If-None-Match header value
116
+ * @param {string|null} currentEtag - Current ETag of the resource (null if doesn't exist)
117
+ * @returns {{ ok: boolean, status?: number, error?: string }}
118
+ */
119
+ export function checkIfNoneMatchForWrite(ifNoneMatchHeader, currentEtag) {
120
+ if (!ifNoneMatchHeader) {
121
+ return { ok: true }; // No header, proceed
122
+ }
123
+
124
+ const etags = parseEtagHeader(ifNoneMatchHeader);
125
+
126
+ // If-None-Match: * means "only if resource doesn't exist"
127
+ if (etags.includes('*')) {
128
+ if (currentEtag !== null) {
129
+ return {
130
+ ok: false,
131
+ status: 412,
132
+ error: 'Precondition Failed: Resource already exists'
133
+ };
134
+ }
135
+ return { ok: true };
136
+ }
137
+
138
+ // Check if any ETag matches (if so, fail)
139
+ if (currentEtag !== null) {
140
+ const normalizedCurrent = normalizeEtag(currentEtag);
141
+ const matches = etags.some(etag => etag === normalizedCurrent);
142
+
143
+ if (matches) {
144
+ return {
145
+ ok: false,
146
+ status: 412,
147
+ error: 'Precondition Failed: ETag matches'
148
+ };
149
+ }
150
+ }
151
+
152
+ return { ok: true };
153
+ }
@@ -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,219 @@
1
+ /**
2
+ * SPARQL Update Tests
3
+ *
4
+ * Tests SPARQL Update support via PATCH method.
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('SPARQL Update', () => {
18
+ before(async () => {
19
+ await startTestServer();
20
+ await createTestPod('sparqltest');
21
+ });
22
+
23
+ after(async () => {
24
+ await stopTestServer();
25
+ });
26
+
27
+ describe('INSERT DATA', () => {
28
+ it('should insert a triple into existing resource', async () => {
29
+ // Create a resource
30
+ await request('/sparqltest/public/insert-test.json', {
31
+ method: 'PUT',
32
+ headers: { 'Content-Type': 'application/ld+json' },
33
+ body: JSON.stringify({ '@id': '#item', 'http://example.org/name': 'Original' }),
34
+ auth: 'sparqltest'
35
+ });
36
+
37
+ // Insert new data via SPARQL Update
38
+ const sparql = `
39
+ PREFIX ex: <http://example.org/>
40
+ INSERT DATA {
41
+ <#item> ex:status "active" .
42
+ }
43
+ `;
44
+
45
+ const res = await request('/sparqltest/public/insert-test.json', {
46
+ method: 'PATCH',
47
+ headers: { 'Content-Type': 'application/sparql-update' },
48
+ body: sparql,
49
+ auth: 'sparqltest'
50
+ });
51
+ assertStatus(res, 204);
52
+
53
+ // Verify the data was inserted
54
+ const getRes = await request('/sparqltest/public/insert-test.json');
55
+ const data = await getRes.json();
56
+ assert.strictEqual(data['http://example.org/status'], 'active');
57
+ assert.strictEqual(data['http://example.org/name'], 'Original');
58
+ });
59
+
60
+ it('should insert multiple triples', async () => {
61
+ await request('/sparqltest/public/multi-insert.json', {
62
+ method: 'PUT',
63
+ headers: { 'Content-Type': 'application/ld+json' },
64
+ body: JSON.stringify({ '@id': '#thing' }),
65
+ auth: 'sparqltest'
66
+ });
67
+
68
+ const sparql = `
69
+ PREFIX ex: <http://example.org/>
70
+ INSERT DATA {
71
+ <#thing> ex:prop1 "value1" .
72
+ <#thing> ex:prop2 "value2" .
73
+ }
74
+ `;
75
+
76
+ const res = await request('/sparqltest/public/multi-insert.json', {
77
+ method: 'PATCH',
78
+ headers: { 'Content-Type': 'application/sparql-update' },
79
+ body: sparql,
80
+ auth: 'sparqltest'
81
+ });
82
+ assertStatus(res, 204);
83
+
84
+ const getRes = await request('/sparqltest/public/multi-insert.json');
85
+ const data = await getRes.json();
86
+ assert.strictEqual(data['http://example.org/prop1'], 'value1');
87
+ assert.strictEqual(data['http://example.org/prop2'], 'value2');
88
+ });
89
+ });
90
+
91
+ describe('DELETE DATA', () => {
92
+ it('should delete a triple from existing resource', async () => {
93
+ await request('/sparqltest/public/delete-test.json', {
94
+ method: 'PUT',
95
+ headers: { 'Content-Type': 'application/ld+json' },
96
+ body: JSON.stringify({
97
+ '@id': '#item',
98
+ 'http://example.org/keep': 'yes',
99
+ 'http://example.org/remove': 'this'
100
+ }),
101
+ auth: 'sparqltest'
102
+ });
103
+
104
+ const sparql = `
105
+ PREFIX ex: <http://example.org/>
106
+ DELETE DATA {
107
+ <#item> ex:remove "this" .
108
+ }
109
+ `;
110
+
111
+ const res = await request('/sparqltest/public/delete-test.json', {
112
+ method: 'PATCH',
113
+ headers: { 'Content-Type': 'application/sparql-update' },
114
+ body: sparql,
115
+ auth: 'sparqltest'
116
+ });
117
+ assertStatus(res, 204);
118
+
119
+ const getRes = await request('/sparqltest/public/delete-test.json');
120
+ const data = await getRes.json();
121
+ assert.strictEqual(data['http://example.org/keep'], 'yes');
122
+ assert.strictEqual(data['http://example.org/remove'], undefined);
123
+ });
124
+ });
125
+
126
+ describe('DELETE/INSERT WHERE', () => {
127
+ it('should delete and insert in single operation', async () => {
128
+ await request('/sparqltest/public/update-test.json', {
129
+ method: 'PUT',
130
+ headers: { 'Content-Type': 'application/ld+json' },
131
+ body: JSON.stringify({
132
+ '@id': '#item',
133
+ 'http://example.org/version': '1'
134
+ }),
135
+ auth: 'sparqltest'
136
+ });
137
+
138
+ const sparql = `
139
+ PREFIX ex: <http://example.org/>
140
+ DELETE { <#item> ex:version "1" }
141
+ INSERT { <#item> ex:version "2" }
142
+ WHERE { <#item> ex:version "1" }
143
+ `;
144
+
145
+ const res = await request('/sparqltest/public/update-test.json', {
146
+ method: 'PATCH',
147
+ headers: { 'Content-Type': 'application/sparql-update' },
148
+ body: sparql,
149
+ auth: 'sparqltest'
150
+ });
151
+ assertStatus(res, 204);
152
+
153
+ const getRes = await request('/sparqltest/public/update-test.json');
154
+ const data = await getRes.json();
155
+ assert.strictEqual(data['http://example.org/version'], '2');
156
+ });
157
+ });
158
+
159
+ describe('Error handling', () => {
160
+ it('should return 404 for non-existent resource', async () => {
161
+ const sparql = `INSERT DATA { <#x> <http://example.org/p> "v" }`;
162
+ const res = await request('/sparqltest/public/nonexistent.json', {
163
+ method: 'PATCH',
164
+ headers: { 'Content-Type': 'application/sparql-update' },
165
+ body: sparql,
166
+ auth: 'sparqltest'
167
+ });
168
+ assertStatus(res, 404);
169
+ });
170
+
171
+ it('should return 415 for unsupported content type', async () => {
172
+ await request('/sparqltest/public/content-type-test.json', {
173
+ method: 'PUT',
174
+ headers: { 'Content-Type': 'application/ld+json' },
175
+ body: JSON.stringify({ '@id': '#test' }),
176
+ auth: 'sparqltest'
177
+ });
178
+
179
+ const res = await request('/sparqltest/public/content-type-test.json', {
180
+ method: 'PATCH',
181
+ headers: { 'Content-Type': 'text/plain' },
182
+ body: 'not a valid patch',
183
+ auth: 'sparqltest'
184
+ });
185
+ assertStatus(res, 415);
186
+ });
187
+ });
188
+
189
+ describe('Typed literals', () => {
190
+ it('should handle integer literals', async () => {
191
+ await request('/sparqltest/public/typed-test.json', {
192
+ method: 'PUT',
193
+ headers: { 'Content-Type': 'application/ld+json' },
194
+ body: JSON.stringify({ '@id': '#data' }),
195
+ auth: 'sparqltest'
196
+ });
197
+
198
+ const sparql = `
199
+ PREFIX ex: <http://example.org/>
200
+ PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
201
+ INSERT DATA {
202
+ <#data> ex:count "42"^^xsd:integer .
203
+ }
204
+ `;
205
+
206
+ const res = await request('/sparqltest/public/typed-test.json', {
207
+ method: 'PATCH',
208
+ headers: { 'Content-Type': 'application/sparql-update' },
209
+ body: sparql,
210
+ auth: 'sparqltest'
211
+ });
212
+ assertStatus(res, 204);
213
+
214
+ const getRes = await request('/sparqltest/public/typed-test.json');
215
+ const data = await getRes.json();
216
+ assert.ok(data['http://example.org/count']);
217
+ });
218
+ });
219
+ });