javascript-solid-server 0.0.150 → 0.0.152
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.
- package/.claude/settings.local.json +3 -1
- package/package.json +1 -1
- package/src/handlers/container.js +3 -3
- package/src/handlers/resource.js +30 -16
- package/src/ldp/headers.js +7 -7
- package/src/rdf/conneg.js +11 -2
- package/src/storage/quota.js +44 -8
- package/test/quota-race.test.js +143 -0
- package/test/vary-cache-headers.test.js +114 -0
|
@@ -355,7 +355,9 @@
|
|
|
355
355
|
"Read(//home/melvin/remote/github.com/solid-helper/core/**)",
|
|
356
356
|
"Bash(curl -s https://www.gnu.org/licenses/agpl-3.0.txt)",
|
|
357
357
|
"Bash(cat LICENSE.full)",
|
|
358
|
-
"Bash(rm LICENSE.full)"
|
|
358
|
+
"Bash(rm LICENSE.full)",
|
|
359
|
+
"Bash(awk -F: '{print $1,$2}')",
|
|
360
|
+
"Bash(awk -F: '{print $1}')"
|
|
359
361
|
]
|
|
360
362
|
}
|
|
361
363
|
}
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
|
|
|
5
5
|
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
|
|
6
6
|
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
|
|
7
7
|
import { createToken } from '../auth/token.js';
|
|
8
|
-
import { canAcceptInput, toJsonLd,
|
|
8
|
+
import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js';
|
|
9
9
|
import { emitChange } from '../notifications/events.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -138,10 +138,10 @@ export async function handlePost(request, reply) {
|
|
|
138
138
|
const headers = getAllHeaders({
|
|
139
139
|
isContainer: isCreatingContainer,
|
|
140
140
|
origin,
|
|
141
|
-
connegEnabled
|
|
141
|
+
connegEnabled,
|
|
142
|
+
mashlibEnabled: request.mashlibEnabled
|
|
142
143
|
});
|
|
143
144
|
headers['Location'] = resourceUrl;
|
|
144
|
-
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
145
145
|
|
|
146
146
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
147
147
|
|
package/src/handlers/resource.js
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
canAcceptInput,
|
|
11
11
|
toJsonLd,
|
|
12
12
|
fromJsonLd,
|
|
13
|
-
getVaryHeader,
|
|
14
13
|
RDF_TYPES
|
|
15
14
|
} from '../rdf/conneg.js';
|
|
16
15
|
import { emitChange } from '../notifications/events.js';
|
|
@@ -22,6 +21,12 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMash
|
|
|
22
21
|
*/
|
|
23
22
|
const LIVE_RELOAD_SCRIPT = `<script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//' +location.host+'/.notifications');ws.onopen=function(){ws.send('sub '+location.href)};ws.onmessage=function(e){if(e.data.startsWith('pub '))location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})();</script>`;
|
|
24
23
|
|
|
24
|
+
// Cache-Control for RDF data responses: let clients keep the body but force
|
|
25
|
+
// revalidation via ETag on every use. This prevents stale bodies from leaking
|
|
26
|
+
// across auth-state changes (WAC) and closes the mashlib render-race window
|
|
27
|
+
// where a cached data variant was served on top-level navigation (#315).
|
|
28
|
+
const RDF_CACHE_CONTROL = 'private, no-cache, must-revalidate';
|
|
29
|
+
|
|
25
30
|
/**
|
|
26
31
|
* Inject live reload script into HTML content
|
|
27
32
|
*/
|
|
@@ -181,6 +186,7 @@ export async function handleGet(request, reply) {
|
|
|
181
186
|
resourceUrl,
|
|
182
187
|
connegEnabled
|
|
183
188
|
});
|
|
189
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
184
190
|
|
|
185
191
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
186
192
|
return reply.send(turtleContent);
|
|
@@ -194,6 +200,7 @@ export async function handleGet(request, reply) {
|
|
|
194
200
|
resourceUrl,
|
|
195
201
|
connegEnabled
|
|
196
202
|
});
|
|
203
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
197
204
|
|
|
198
205
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
199
206
|
return reply.send(JSON.stringify(jsonLd, null, 2));
|
|
@@ -239,9 +246,9 @@ export async function handleGet(request, reply) {
|
|
|
239
246
|
contentType: 'text/html',
|
|
240
247
|
origin,
|
|
241
248
|
resourceUrl,
|
|
242
|
-
connegEnabled
|
|
249
|
+
connegEnabled,
|
|
250
|
+
mashlibEnabled: request.mashlibEnabled
|
|
243
251
|
});
|
|
244
|
-
headers['Vary'] = 'Accept';
|
|
245
252
|
headers['X-Frame-Options'] = 'DENY';
|
|
246
253
|
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
|
|
247
254
|
headers['Cache-Control'] = 'no-store';
|
|
@@ -274,9 +281,10 @@ export async function handleGet(request, reply) {
|
|
|
274
281
|
contentType: 'text/turtle',
|
|
275
282
|
origin,
|
|
276
283
|
resourceUrl,
|
|
277
|
-
connegEnabled
|
|
284
|
+
connegEnabled,
|
|
285
|
+
mashlibEnabled: request.mashlibEnabled
|
|
278
286
|
});
|
|
279
|
-
headers['
|
|
287
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
280
288
|
|
|
281
289
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
282
290
|
return reply.send(turtleContent);
|
|
@@ -292,8 +300,10 @@ export async function handleGet(request, reply) {
|
|
|
292
300
|
contentType: 'application/ld+json',
|
|
293
301
|
origin,
|
|
294
302
|
resourceUrl,
|
|
295
|
-
connegEnabled
|
|
303
|
+
connegEnabled,
|
|
304
|
+
mashlibEnabled: request.mashlibEnabled
|
|
296
305
|
});
|
|
306
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
297
307
|
|
|
298
308
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
299
309
|
return reply.send(serializeJsonLd(jsonLd));
|
|
@@ -315,9 +325,9 @@ export async function handleGet(request, reply) {
|
|
|
315
325
|
contentType: 'text/html',
|
|
316
326
|
origin,
|
|
317
327
|
resourceUrl,
|
|
318
|
-
connegEnabled
|
|
328
|
+
connegEnabled,
|
|
329
|
+
mashlibEnabled: request.mashlibEnabled
|
|
319
330
|
});
|
|
320
|
-
headers['Vary'] = 'Accept';
|
|
321
331
|
headers['X-Frame-Options'] = 'DENY';
|
|
322
332
|
headers['Content-Security-Policy'] = "frame-ancestors 'none'";
|
|
323
333
|
// Don't cache the HTML wrapper - always negotiate fresh
|
|
@@ -397,9 +407,10 @@ export async function handleGet(request, reply) {
|
|
|
397
407
|
contentType: 'text/turtle',
|
|
398
408
|
origin,
|
|
399
409
|
resourceUrl,
|
|
400
|
-
connegEnabled
|
|
410
|
+
connegEnabled,
|
|
411
|
+
mashlibEnabled: request.mashlibEnabled
|
|
401
412
|
});
|
|
402
|
-
headers['
|
|
413
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
403
414
|
|
|
404
415
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
405
416
|
return reply.send(turtleContent);
|
|
@@ -427,9 +438,10 @@ export async function handleGet(request, reply) {
|
|
|
427
438
|
contentType: outputType,
|
|
428
439
|
origin,
|
|
429
440
|
resourceUrl,
|
|
430
|
-
connegEnabled
|
|
441
|
+
connegEnabled,
|
|
442
|
+
mashlibEnabled: request.mashlibEnabled
|
|
431
443
|
});
|
|
432
|
-
headers['
|
|
444
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
433
445
|
|
|
434
446
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
435
447
|
return reply.send(outputContent);
|
|
@@ -455,9 +467,12 @@ export async function handleGet(request, reply) {
|
|
|
455
467
|
contentType: actualContentType,
|
|
456
468
|
origin,
|
|
457
469
|
resourceUrl,
|
|
458
|
-
connegEnabled
|
|
470
|
+
connegEnabled,
|
|
471
|
+
mashlibEnabled: request.mashlibEnabled
|
|
459
472
|
});
|
|
460
|
-
|
|
473
|
+
if (isRdfContentType(actualContentType)) {
|
|
474
|
+
headers['Cache-Control'] = RDF_CACHE_CONTROL;
|
|
475
|
+
}
|
|
461
476
|
|
|
462
477
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
463
478
|
|
|
@@ -671,9 +686,8 @@ export async function handlePut(request, reply) {
|
|
|
671
686
|
}
|
|
672
687
|
|
|
673
688
|
const origin = request.headers.origin;
|
|
674
|
-
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
|
|
689
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled, mashlibEnabled: request.mashlibEnabled });
|
|
675
690
|
headers['Location'] = resourceUrl;
|
|
676
|
-
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
677
691
|
|
|
678
692
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
679
693
|
|
package/src/ldp/headers.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* LDP (Linked Data Platform) header utilities
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { getAcceptHeaders } from '../rdf/conneg.js';
|
|
5
|
+
import { getAcceptHeaders, getVaryHeader } from '../rdf/conneg.js';
|
|
6
6
|
|
|
7
7
|
const LDP = 'http://www.w3.org/ns/ldp#';
|
|
8
8
|
|
|
@@ -49,7 +49,7 @@ export function getAclUrl(resourceUrl, isContainer) {
|
|
|
49
49
|
* @param {object} options
|
|
50
50
|
* @returns {object}
|
|
51
51
|
*/
|
|
52
|
-
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
|
|
52
|
+
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
|
|
53
53
|
// Calculate ACL URL if resource URL provided
|
|
54
54
|
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
|
|
55
55
|
|
|
@@ -58,7 +58,7 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
|
|
|
58
58
|
'Accept-Patch': 'text/n3, application/sparql-update',
|
|
59
59
|
'Accept-Ranges': isContainer ? 'none' : 'bytes',
|
|
60
60
|
'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
61
|
-
'Vary': connegEnabled
|
|
61
|
+
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
// Only set WAC-Allow if explicitly provided (otherwise the auth hook sets it)
|
|
@@ -107,9 +107,9 @@ export function getCorsHeaders(origin) {
|
|
|
107
107
|
* @param {object} options
|
|
108
108
|
* @returns {object}
|
|
109
109
|
*/
|
|
110
|
-
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, updatesVia = null }) {
|
|
110
|
+
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false, mashlibEnabled = false, updatesVia = null }) {
|
|
111
111
|
return {
|
|
112
|
-
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, updatesVia }),
|
|
112
|
+
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled, mashlibEnabled, updatesVia }),
|
|
113
113
|
...getCorsHeaders(origin)
|
|
114
114
|
};
|
|
115
115
|
}
|
|
@@ -120,7 +120,7 @@ export function getAllHeaders({ isContainer = false, etag = null, contentType =
|
|
|
120
120
|
* @param {object} options
|
|
121
121
|
* @returns {object}
|
|
122
122
|
*/
|
|
123
|
-
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false }) {
|
|
123
|
+
export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEnabled = false, mashlibEnabled = false }) {
|
|
124
124
|
// Determine if this would be a container based on URL ending with /
|
|
125
125
|
const isContainer = resourceUrl?.endsWith('/') || false;
|
|
126
126
|
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
|
|
@@ -134,7 +134,7 @@ export function getNotFoundHeaders({ resourceUrl = null, origin = null, connegEn
|
|
|
134
134
|
'Accept-Patch': 'text/n3, application/sparql-update',
|
|
135
135
|
'Accept-Put': acceptHeaders['Accept-Put'] || 'application/ld+json, */*',
|
|
136
136
|
'Allow': 'GET, HEAD, PUT, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
137
|
-
'Vary': connegEnabled
|
|
137
|
+
'Vary': getVaryHeader(connegEnabled, mashlibEnabled)
|
|
138
138
|
};
|
|
139
139
|
|
|
140
140
|
if (isContainer && acceptHeaders['Accept-Post']) {
|
package/src/rdf/conneg.js
CHANGED
|
@@ -189,10 +189,19 @@ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = fa
|
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
191
|
* Get Vary header value for content negotiation
|
|
192
|
-
*
|
|
192
|
+
*
|
|
193
|
+
* Must be identical across all variants of a given URL — inconsistent Vary
|
|
194
|
+
* across variants confuses browser caches and can cause the wrong variant
|
|
195
|
+
* to be served on reload (see #315).
|
|
196
|
+
*
|
|
197
|
+
* - `Accept` — response body depends on Accept (conneg or mashlib HTML shell)
|
|
198
|
+
* - `Authorization` — response body depends on the authenticated user (WAC)
|
|
199
|
+
* - `Origin` — CORS headers echo the request's Origin
|
|
193
200
|
*/
|
|
194
201
|
export function getVaryHeader(connegEnabled, mashlibEnabled = false) {
|
|
195
|
-
return (connegEnabled || mashlibEnabled)
|
|
202
|
+
return (connegEnabled || mashlibEnabled)
|
|
203
|
+
? 'Accept, Authorization, Origin'
|
|
204
|
+
: 'Authorization, Origin';
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
/**
|
package/src/storage/quota.js
CHANGED
|
@@ -8,6 +8,10 @@ import { join } from 'path';
|
|
|
8
8
|
import { getDataRoot } from '../utils/url.js';
|
|
9
9
|
|
|
10
10
|
const QUOTA_FILE = '.quota.json';
|
|
11
|
+
// Temp files created by saveQuota live next to the quota file; they must be
|
|
12
|
+
// excluded from disk-usage reconciliation so an orphan (e.g. from a crash
|
|
13
|
+
// between writeFile and rename) doesn't inflate pod usage.
|
|
14
|
+
const QUOTA_TMP_PREFIX = `${QUOTA_FILE}.tmp.`;
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* Get quota file path for a pod
|
|
@@ -16,6 +20,18 @@ function getQuotaPath(podName) {
|
|
|
16
20
|
return join(getDataRoot(), podName, QUOTA_FILE);
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Coerce a parsed quota object to a sane shape so all callers can do
|
|
25
|
+
* arithmetic on it without worrying about undefined/NaN/negative values.
|
|
26
|
+
*/
|
|
27
|
+
function sanitizeQuota(q) {
|
|
28
|
+
const toNum = (v) => {
|
|
29
|
+
const n = Number(v);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
31
|
+
};
|
|
32
|
+
return { limit: toNum(q?.limit), used: toNum(q?.used) };
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
/**
|
|
20
36
|
* Load quota data for a pod
|
|
21
37
|
* @param {string} podName - The pod name
|
|
@@ -24,11 +40,15 @@ function getQuotaPath(podName) {
|
|
|
24
40
|
export async function loadQuota(podName) {
|
|
25
41
|
try {
|
|
26
42
|
const data = await fs.readFile(getQuotaPath(podName), 'utf-8');
|
|
27
|
-
|
|
43
|
+
// Empty/partial file can appear if a write was interrupted (or from a
|
|
44
|
+
// racing non-atomic save on older versions). Treat as missing and
|
|
45
|
+
// reconcile against on-disk usage so we don't under-count.
|
|
46
|
+
if (!data.trim()) return { limit: 0, used: await calculatePodSize(podName) };
|
|
47
|
+
return sanitizeQuota(JSON.parse(data));
|
|
28
48
|
} catch (err) {
|
|
29
|
-
if (err.code === 'ENOENT') {
|
|
30
|
-
|
|
31
|
-
return { limit: 0, used:
|
|
49
|
+
if (err.code === 'ENOENT') return { limit: 0, used: 0 };
|
|
50
|
+
if (err instanceof SyntaxError) {
|
|
51
|
+
return { limit: 0, used: await calculatePodSize(podName) };
|
|
32
52
|
}
|
|
33
53
|
throw err;
|
|
34
54
|
}
|
|
@@ -40,7 +60,19 @@ export async function loadQuota(podName) {
|
|
|
40
60
|
* @param {object} quota - Quota data
|
|
41
61
|
*/
|
|
42
62
|
export async function saveQuota(podName, quota) {
|
|
43
|
-
|
|
63
|
+
// Atomic write: fs.writeFile truncates before writing, which lets concurrent
|
|
64
|
+
// readers see an empty file. Write to a temp file and rename (POSIX-atomic).
|
|
65
|
+
// Any failure (writeFile or rename) cleans up the temp file so failed writes
|
|
66
|
+
// don't accumulate orphans.
|
|
67
|
+
const finalPath = getQuotaPath(podName);
|
|
68
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
69
|
+
try {
|
|
70
|
+
await fs.writeFile(tmpPath, JSON.stringify(quota, null, 2));
|
|
71
|
+
await fs.rename(tmpPath, finalPath);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
await fs.unlink(tmpPath).catch(() => {});
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
44
76
|
}
|
|
45
77
|
|
|
46
78
|
/**
|
|
@@ -76,8 +108,9 @@ async function calculateDirSize(dirPath) {
|
|
|
76
108
|
for (const entry of entries) {
|
|
77
109
|
const fullPath = join(dirPath, entry.name);
|
|
78
110
|
|
|
79
|
-
// Skip quota file
|
|
111
|
+
// Skip the quota file and any orphaned temp files from saveQuota.
|
|
80
112
|
if (entry.name === QUOTA_FILE) continue;
|
|
113
|
+
if (entry.name.startsWith(QUOTA_TMP_PREFIX)) continue;
|
|
81
114
|
|
|
82
115
|
if (entry.isDirectory()) {
|
|
83
116
|
total += await calculateDirSize(fullPath);
|
|
@@ -106,9 +139,12 @@ async function calculateDirSize(dirPath) {
|
|
|
106
139
|
export async function checkQuota(podName, additionalBytes, defaultQuota) {
|
|
107
140
|
let quota = await loadQuota(podName);
|
|
108
141
|
|
|
109
|
-
// Initialize if no quota set
|
|
142
|
+
// Initialize if no quota set. loadQuota already sanitizes shape, so `used`
|
|
143
|
+
// is a finite non-negative number here — preserve it so reconciled usage
|
|
144
|
+
// from a recovered corrupt/empty file is not reset to 0.
|
|
110
145
|
if (quota.limit === 0 && defaultQuota > 0) {
|
|
111
|
-
quota =
|
|
146
|
+
quota = { limit: defaultQuota, used: quota.used };
|
|
147
|
+
await saveQuota(podName, quota);
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
// No quota enforcement if limit is 0
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #309 — concurrent quota updates causing 500s.
|
|
3
|
+
*
|
|
4
|
+
* Without atomic writes, two concurrent updateQuotaUsage calls race on
|
|
5
|
+
* .quota.json — the second load can read an empty/partial file and
|
|
6
|
+
* JSON.parse throws "Unexpected end of JSON input".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, before, after } from 'node:test';
|
|
10
|
+
import assert from 'node:assert';
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import {
|
|
15
|
+
initializeQuota,
|
|
16
|
+
updateQuotaUsage,
|
|
17
|
+
loadQuota,
|
|
18
|
+
saveQuota,
|
|
19
|
+
checkQuota,
|
|
20
|
+
calculatePodSize
|
|
21
|
+
} from '../src/storage/quota.js';
|
|
22
|
+
|
|
23
|
+
const POD = 'testpod';
|
|
24
|
+
let TEST_ROOT;
|
|
25
|
+
let originalDataRoot;
|
|
26
|
+
|
|
27
|
+
describe('quota — concurrent updates (#309)', () => {
|
|
28
|
+
before(async () => {
|
|
29
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
30
|
+
TEST_ROOT = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-quota-'));
|
|
31
|
+
process.env.DATA_ROOT = TEST_ROOT;
|
|
32
|
+
await fs.ensureDir(path.join(TEST_ROOT, POD));
|
|
33
|
+
await initializeQuota(POD, 50 * 1024 * 1024);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
after(async () => {
|
|
37
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
38
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
39
|
+
await fs.remove(TEST_ROOT);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('many concurrent updates do not throw', async () => {
|
|
43
|
+
const N = 50;
|
|
44
|
+
const updates = Array.from({ length: N }, (_, i) =>
|
|
45
|
+
updateQuotaUsage(POD, 10 + i)
|
|
46
|
+
);
|
|
47
|
+
await assert.doesNotReject(Promise.all(updates));
|
|
48
|
+
const final = await loadQuota(POD);
|
|
49
|
+
assert.strictEqual(typeof final.used, 'number');
|
|
50
|
+
assert.ok(final.used > 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function withResourceAndBrokenQuota(brokenContent) {
|
|
54
|
+
const resourcePath = path.join(TEST_ROOT, POD, 'resource.txt');
|
|
55
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
56
|
+
const size = 321;
|
|
57
|
+
await fs.writeFile(resourcePath, 'x'.repeat(size));
|
|
58
|
+
await fs.writeFile(quotaPath, brokenContent);
|
|
59
|
+
return { size, cleanup: () => fs.remove(resourcePath) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('loadQuota reconciles usage from disk when quota file is empty', async () => {
|
|
63
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('');
|
|
64
|
+
try {
|
|
65
|
+
const q = await loadQuota(POD);
|
|
66
|
+
assert.strictEqual(q.limit, 0);
|
|
67
|
+
assert.strictEqual(q.used, size);
|
|
68
|
+
} finally { await cleanup(); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('loadQuota reconciles usage from disk when quota file is corrupt', async () => {
|
|
72
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('{"limit":524');
|
|
73
|
+
try {
|
|
74
|
+
const q = await loadQuota(POD);
|
|
75
|
+
assert.strictEqual(q.limit, 0);
|
|
76
|
+
assert.strictEqual(q.used, size);
|
|
77
|
+
} finally { await cleanup(); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('loadQuota sanitizes malformed fields (missing/null/negative)', async () => {
|
|
81
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
82
|
+
const cases = [
|
|
83
|
+
['{"limit":0}', { limit: 0, used: 0 }],
|
|
84
|
+
['{"limit":null,"used":null}', { limit: 0, used: 0 }],
|
|
85
|
+
['{"limit":-10,"used":-5}', { limit: 0, used: 0 }],
|
|
86
|
+
// Numeric strings are coerced — tolerates legacy/manually-edited files.
|
|
87
|
+
['{"limit":"100","used":"50"}', { limit: 100, used: 50 }]
|
|
88
|
+
];
|
|
89
|
+
for (const [body, expected] of cases) {
|
|
90
|
+
await fs.writeFile(quotaPath, body);
|
|
91
|
+
const q = await loadQuota(POD);
|
|
92
|
+
assert.deepStrictEqual(q, expected, `for ${body}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('calculatePodSize ignores orphaned quota temp files', async () => {
|
|
97
|
+
// Simulate an orphan from a crashed saveQuota (process died between
|
|
98
|
+
// writeFile and rename) — must not be counted toward pod usage.
|
|
99
|
+
const resource = path.join(TEST_ROOT, POD, 'data.txt');
|
|
100
|
+
const orphan = path.join(TEST_ROOT, POD, '.quota.json.tmp.999.1.abc');
|
|
101
|
+
await fs.writeFile(resource, 'x'.repeat(50));
|
|
102
|
+
await fs.writeFile(orphan, 'x'.repeat(9999));
|
|
103
|
+
try {
|
|
104
|
+
const size = await calculatePodSize(POD);
|
|
105
|
+
assert.strictEqual(size, 50, 'orphan temp file must not be counted');
|
|
106
|
+
} finally {
|
|
107
|
+
await fs.remove(resource);
|
|
108
|
+
await fs.remove(orphan);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('checkQuota preserves reconciled usage when re-initializing limit', async () => {
|
|
113
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('');
|
|
114
|
+
try {
|
|
115
|
+
const defaultQuota = 10 * 1024 * 1024;
|
|
116
|
+
const { quota } = await checkQuota(POD, 0, defaultQuota);
|
|
117
|
+
assert.strictEqual(quota.limit, defaultQuota);
|
|
118
|
+
assert.strictEqual(quota.used, size, 'reconciled usage must not be reset to 0');
|
|
119
|
+
} finally { await cleanup(); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('saveQuota is atomic — concurrent read during save never sees empty file', async () => {
|
|
123
|
+
await saveQuota(POD, { limit: 1000, used: 100 });
|
|
124
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
125
|
+
|
|
126
|
+
// Interleave 200 saves with 200 reads; with non-atomic writes, at least
|
|
127
|
+
// one read would land on a truncated file — the empty-file assertion
|
|
128
|
+
// below (or the JSON.parse) would then fail.
|
|
129
|
+
const ops = [];
|
|
130
|
+
for (let i = 0; i < 200; i++) {
|
|
131
|
+
ops.push(saveQuota(POD, { limit: 1000, used: i }));
|
|
132
|
+
ops.push(fs.readFile(quotaPath, 'utf-8').then((data) => {
|
|
133
|
+
assert.notStrictEqual(
|
|
134
|
+
data.length,
|
|
135
|
+
0,
|
|
136
|
+
'concurrent read saw an empty quota file'
|
|
137
|
+
);
|
|
138
|
+
JSON.parse(data);
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
await assert.doesNotReject(Promise.all(ops));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #315 — inconsistent Vary / Cache-Control across
|
|
3
|
+
* conneg variants caused stale-render races on browser reload.
|
|
4
|
+
*
|
|
5
|
+
* What we guarantee now:
|
|
6
|
+
* - Every variant of the same URL returns an *identical* Vary header.
|
|
7
|
+
* - RDF data variants carry Cache-Control that forces revalidation via
|
|
8
|
+
* ETag, so a cached body cannot silently serve across auth changes or
|
|
9
|
+
* be picked up on a top-level navigation by mistake.
|
|
10
|
+
* - The mashlib HTML wrapper keeps `no-store` (it's a bootstrap template).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, before, after } from 'node:test';
|
|
14
|
+
import assert from 'node:assert';
|
|
15
|
+
import {
|
|
16
|
+
startTestServer,
|
|
17
|
+
stopTestServer,
|
|
18
|
+
request,
|
|
19
|
+
createTestPod
|
|
20
|
+
} from './helpers.js';
|
|
21
|
+
|
|
22
|
+
describe('Vary / Cache-Control consistency (#315)', () => {
|
|
23
|
+
before(async () => {
|
|
24
|
+
await startTestServer({ conneg: true, mashlibCdn: true });
|
|
25
|
+
await createTestPod('varytest');
|
|
26
|
+
// Create a JSON-LD resource to exercise all variants.
|
|
27
|
+
await request('/varytest/public/card.jsonld', {
|
|
28
|
+
method: 'PUT',
|
|
29
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
'@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
|
|
32
|
+
'@id': '#me',
|
|
33
|
+
'foaf:name': 'Vary Test'
|
|
34
|
+
}),
|
|
35
|
+
auth: 'varytest'
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
after(async () => { await stopTestServer(); });
|
|
40
|
+
|
|
41
|
+
it('Vary header is identical across all conneg variants of the same URL', async () => {
|
|
42
|
+
const accepts = [
|
|
43
|
+
'text/html,*/*;q=0.8', // mashlib HTML wrapper
|
|
44
|
+
'text/turtle', // Turtle conversion
|
|
45
|
+
'application/ld+json' // native JSON-LD
|
|
46
|
+
];
|
|
47
|
+
const varyValues = [];
|
|
48
|
+
for (const accept of accepts) {
|
|
49
|
+
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
|
|
50
|
+
varyValues.push({ accept, vary: res.headers.get('vary') });
|
|
51
|
+
}
|
|
52
|
+
// All three variants must carry the same Vary — inconsistent Vary is
|
|
53
|
+
// what confused browser caches into serving the wrong variant.
|
|
54
|
+
const uniqueVaryValues = new Set(varyValues.map((v) => v.vary));
|
|
55
|
+
assert.strictEqual(uniqueVaryValues.size, 1,
|
|
56
|
+
`expected identical Vary across variants, got: ${JSON.stringify(varyValues)}`);
|
|
57
|
+
const vary = [...uniqueVaryValues][0];
|
|
58
|
+
assert.ok(vary, `expected Vary header across variants, got: ${JSON.stringify(varyValues)}`);
|
|
59
|
+
assert.match(vary, /Accept/, 'Vary must include Accept (conneg active)');
|
|
60
|
+
assert.match(vary, /Authorization/, 'Vary must include Authorization (WAC)');
|
|
61
|
+
assert.match(vary, /Origin/, 'Vary must include Origin (CORS)');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('mashlib HTML wrapper uses Cache-Control: no-store', async () => {
|
|
65
|
+
const res = await request('/varytest/public/card.jsonld', {
|
|
66
|
+
headers: { Accept: 'text/html,*/*;q=0.8' }
|
|
67
|
+
});
|
|
68
|
+
assert.match(res.headers.get('content-type') || '', /text\/html/);
|
|
69
|
+
assert.strictEqual(res.headers.get('cache-control'), 'no-store');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('RDF data variants force revalidation (no stale bodies across auth changes)', async () => {
|
|
73
|
+
// Full expected policy — pinning every directive so a regression that
|
|
74
|
+
// drops `private` or `must-revalidate` (both needed to prevent auth-state
|
|
75
|
+
// leakage and force freshness) fails the test.
|
|
76
|
+
const expected = 'private, no-cache, must-revalidate';
|
|
77
|
+
for (const accept of ['text/turtle', 'application/ld+json']) {
|
|
78
|
+
const res = await request('/varytest/public/card.jsonld', { headers: { Accept: accept } });
|
|
79
|
+
assert.strictEqual(res.headers.get('cache-control'), expected,
|
|
80
|
+
`Cache-Control mismatch on Accept: ${accept}`);
|
|
81
|
+
// ETag is preserved so revalidation is cheap (304).
|
|
82
|
+
assert.ok(res.headers.get('etag'), `expected ETag on ${accept} variant`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('container index.html data-island variants also carry revalidating Cache-Control', async () => {
|
|
87
|
+
// Publish an index.html with a JSON-LD data island; conneg should extract
|
|
88
|
+
// and serve it as Turtle/JSON-LD. Those variants were missing
|
|
89
|
+
// Cache-Control pre-#315.
|
|
90
|
+
const html = [
|
|
91
|
+
'<!doctype html><html><head>',
|
|
92
|
+
'<script type="application/ld+json">',
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
'@context': { foaf: 'http://xmlns.com/foaf/0.1/' },
|
|
95
|
+
'@id': '#this',
|
|
96
|
+
'foaf:name': 'Island'
|
|
97
|
+
}),
|
|
98
|
+
'</script></head><body>hi</body></html>'
|
|
99
|
+
].join('');
|
|
100
|
+
await request('/varytest/public/index.html', {
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
headers: { 'Content-Type': 'text/html' },
|
|
103
|
+
body: html,
|
|
104
|
+
auth: 'varytest'
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const expected = 'private, no-cache, must-revalidate';
|
|
108
|
+
for (const accept of ['text/turtle', 'application/ld+json']) {
|
|
109
|
+
const res = await request('/varytest/public/', { headers: { Accept: accept } });
|
|
110
|
+
assert.strictEqual(res.headers.get('cache-control'), expected,
|
|
111
|
+
`Cache-Control mismatch on island variant (Accept: ${accept})`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|