koa-classic-server 3.0.0-alpha.0 → 3.0.0

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.
@@ -1,11 +1,21 @@
1
1
  const supertest = require('supertest');
2
+ const crypto = require('crypto');
2
3
  const koaClassicServer = require('../index.cjs');
3
4
  const Koa = require('koa');
4
5
  const path = require('path');
5
6
 
6
7
  const root = path.join(__dirname, 'publicWwwTest');
7
8
 
8
- const LISTING_CSP = "default-src 'none'; style-src 'sha256-9izM/ygZXy3xF1fZ8DQP0Tovpqy5fBMn4e6vf7Xs04A='; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
9
+ // Compute the expected CSP hash from the actual inline CSS in the response, so
10
+ // CSS edits to the listing template do not require updating a hardcoded hash here.
11
+ async function expectedListingCsp(server, requestPath = '/') {
12
+ const res = await supertest(server).get(requestPath);
13
+ const m = res.text.match(/<style>([\s\S]*?)<\/style>/);
14
+ if (!m) throw new Error('No <style> block in listing HTML — cannot compute expected CSP');
15
+ const cssHash = 'sha256-' + crypto.createHash('sha256').update(m[1], 'utf8').digest('base64');
16
+ return `default-src 'none'; style-src '${cssHash}'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`;
17
+ }
18
+
9
19
  const NOT_FOUND_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
10
20
 
11
21
  const COMMON_HEADERS = {
@@ -17,7 +27,7 @@ const COMMON_HEADERS = {
17
27
 
18
28
  function createApp(opts = {}) {
19
29
  const app = new Koa();
20
- app.use(koaClassicServer(root, { showDirContents: true, ...opts }));
30
+ app.use(koaClassicServer(root, { dirListing: { enabled: true }, ...opts }));
21
31
  return app.listen();
22
32
  }
23
33
 
@@ -30,7 +40,7 @@ describe('Security headers — directory listing page', () => {
30
40
 
31
41
  test('Content-Security-Policy uses hash-based style-src', async () => {
32
42
  const res = await supertest(server).get('/');
33
- expect(res.headers['content-security-policy']).toBe(LISTING_CSP);
43
+ expect(res.headers['content-security-policy']).toBe(await expectedListingCsp(server));
34
44
  });
35
45
 
36
46
  test('X-Content-Type-Options: nosniff', async () => {
@@ -62,8 +72,10 @@ describe('Security headers — directory listing page', () => {
62
72
 
63
73
  test('CSP style-src hash in listing matches actual inline CSS', async () => {
64
74
  const res = await supertest(server).get('/');
65
- // The hash must appear in the CSP header
66
- expect(res.headers['content-security-policy']).toContain('sha256-9izM/ygZXy3xF1fZ8DQP0Tovpqy5fBMn4e6vf7Xs04A=');
75
+ const styleMatch = res.text.match(/<style>([\s\S]*?)<\/style>/);
76
+ expect(styleMatch).toBeTruthy();
77
+ const expectedHash = 'sha256-' + crypto.createHash('sha256').update(styleMatch[1], 'utf8').digest('base64');
78
+ expect(res.headers['content-security-policy']).toContain(expectedHash);
67
79
  });
68
80
  });
69
81
 
@@ -93,11 +105,11 @@ describe('Security headers — 404 Not Found page', () => {
93
105
  });
94
106
  });
95
107
 
96
- // ─── Directory listing disabled (showDirContents: false) ─────────────────────
108
+ // ─── Directory listing disabled (dirListing: { enabled: false }) ─────────────────────
97
109
 
98
110
  describe('Security headers — 404 when directory listing disabled', () => {
99
111
  let server;
100
- beforeAll(() => { server = createApp({ showDirContents: false }); });
112
+ beforeAll(() => { server = createApp({ dirListing: { enabled: false } }); });
101
113
  afterAll(() => server.close());
102
114
 
103
115
  test('Security headers present when directory listing disabled', async () => {
@@ -145,7 +157,7 @@ describe('Security headers — subdirectory listing page', () => {
145
157
  test('Listing of subdirectory also has security headers', async () => {
146
158
  const res = await supertest(server).get('/cartella/');
147
159
  expect(res.status).toBe(200);
148
- expect(res.headers['content-security-policy']).toBe(LISTING_CSP);
160
+ expect(res.headers['content-security-policy']).toBe(await expectedListingCsp(server, '/cartella/'));
149
161
  for (const [header, value] of Object.entries(COMMON_HEADERS)) {
150
162
  expect(res.headers[header]).toBe(value);
151
163
  }
@@ -22,7 +22,7 @@ describe('Security Tests - Path Traversal', () => {
22
22
  beforeAll(() => {
23
23
  app = new Koa();
24
24
  app.use(koaClassicServer(rootDir, {
25
- showDirContents: true
25
+ dirListing: { enabled: true }
26
26
  }));
27
27
  server = app.listen();
28
28
  });
@@ -79,7 +79,7 @@ describe('Bug Tests - Status Code 404', () => {
79
79
  beforeAll(() => {
80
80
  app = new Koa();
81
81
  app.use(koaClassicServer(rootDir, {
82
- showDirContents: true
82
+ dirListing: { enabled: true }
83
83
  }));
84
84
  server = app.listen();
85
85
  });
@@ -90,10 +90,10 @@ describe('Bug Tests - Status Code 404', () => {
90
90
  expect(res.text).toContain('Not Found');
91
91
  });
92
92
 
93
- test('FIXED: Directory with showDirContents=false returns 404', async () => {
93
+ test('FIXED: Directory with dirListing.enabled=false returns 404', async () => {
94
94
  const app2 = new Koa();
95
95
  app2.use(koaClassicServer(rootDir, {
96
- showDirContents: false
96
+ dirListing: { enabled: false }
97
97
  }));
98
98
  const server2 = app2.listen();
99
99
 
@@ -235,7 +235,7 @@ describe('Bug Tests - Directory Read Errors', () => {
235
235
  const tempDir = path.join(rootDir, 'temp-perm-test-dir');
236
236
  if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir);
237
237
 
238
- app.use(koaClassicServer(rootDir, { showDirContents: true }));
238
+ app.use(koaClassicServer(rootDir, { dirListing: { enabled: true } }));
239
239
  const server = app.listen();
240
240
 
241
241
  try {
@@ -28,7 +28,7 @@ const fixturesDir = path.join(__dirname, 'server-cache-fixtures');
28
28
 
29
29
  function createApp(opts = {}) {
30
30
  const app = new Koa();
31
- app.use(koaClassicServer(fixturesDir, { showDirContents: false, ...opts }));
31
+ app.use(koaClassicServer(fixturesDir, { dirListing: { enabled: false }, ...opts }));
32
32
  return app.listen();
33
33
  }
34
34
 
@@ -124,7 +124,7 @@ describe('serverCache.rawFile — cache invalidation on file change', () => {
124
124
 
125
125
  const app = new Koa();
126
126
  app.use(koaClassicServer(tmpDir, {
127
- showDirContents: false,
127
+ dirListing: { enabled: false },
128
128
  serverCache: { rawFile: { enabled: true } }
129
129
  }));
130
130
  server = app.listen();
@@ -156,6 +156,177 @@ describe('serverCache.rawFile — cache invalidation on file change', () => {
156
156
  });
157
157
  });
158
158
 
159
+ // ─── maxAge: factory validation ───────────────────────────────────────────────
160
+
161
+ describe('serverCache.*.maxAge — factory validation', () => {
162
+ test('rejects negative rawFile.maxAge', () => {
163
+ expect(() => koaClassicServer(fixturesDir, {
164
+ serverCache: { rawFile: { enabled: true, maxAge: -1 } }
165
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
166
+ });
167
+
168
+ test('rejects NaN rawFile.maxAge', () => {
169
+ expect(() => koaClassicServer(fixturesDir, {
170
+ serverCache: { rawFile: { enabled: true, maxAge: NaN } }
171
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
172
+ });
173
+
174
+ test('rejects Infinity rawFile.maxAge', () => {
175
+ expect(() => koaClassicServer(fixturesDir, {
176
+ serverCache: { rawFile: { enabled: true, maxAge: Infinity } }
177
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
178
+ });
179
+
180
+ test('rejects string rawFile.maxAge', () => {
181
+ expect(() => koaClassicServer(fixturesDir, {
182
+ serverCache: { rawFile: { enabled: true, maxAge: '5000' } }
183
+ })).toThrow(/rawFile\.maxAge must be a finite number/);
184
+ });
185
+
186
+ test('rejects negative compressedFile.maxAge', () => {
187
+ expect(() => koaClassicServer(fixturesDir, {
188
+ serverCache: { compressedFile: { maxAge: -1 } }
189
+ })).toThrow(/compressedFile\.maxAge must be a finite number/);
190
+ });
191
+
192
+ test('accepts 0 (disabled) and positive integers', () => {
193
+ expect(() => koaClassicServer(fixturesDir, {
194
+ serverCache: { rawFile: { enabled: true, maxAge: 0 } }
195
+ })).not.toThrow();
196
+ expect(() => koaClassicServer(fixturesDir, {
197
+ serverCache: { rawFile: { enabled: true, maxAge: 1000 } }
198
+ })).not.toThrow();
199
+ });
200
+ });
201
+
202
+ // ─── maxAge: rawFile staleness ────────────────────────────────────────────────
203
+
204
+ describe('serverCache.rawFile.maxAge — time-based staleness', () => {
205
+ // Verify maxAge triggers a fresh disk read even when mtime+size are unchanged.
206
+ // Simulates the NFS attribute-cache scenario by manually restoring mtime after
207
+ // editing the file, so the mtime-based invariant alone cannot detect the change.
208
+ let tmpDir;
209
+ let filePath;
210
+ let server;
211
+
212
+ beforeAll(() => {
213
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-maxage-raw-'));
214
+ filePath = path.join(tmpDir, 'data.txt');
215
+ fs.writeFileSync(filePath, 'version-A');
216
+
217
+ const app = new Koa();
218
+ app.use(koaClassicServer(tmpDir, {
219
+ dirListing: { enabled: false },
220
+ serverCache: { rawFile: { enabled: true, maxAge: 100 } }
221
+ }));
222
+ server = app.listen();
223
+ });
224
+
225
+ afterAll(() => {
226
+ server.close();
227
+ fs.rmSync(tmpDir, { recursive: true, force: true });
228
+ });
229
+
230
+ test('within maxAge: cache is served (no disk re-read)', async () => {
231
+ const first = await supertest(server).get('/data.txt').set('Accept-Encoding', 'identity');
232
+ expect(first.text).toBe('version-A');
233
+
234
+ // Replace content but freeze mtime to its original value (simulates NFS lying about mtime).
235
+ // Length must stay identical so the size check doesn't invalidate independently of maxAge.
236
+ const originalStat = fs.statSync(filePath);
237
+ fs.writeFileSync(filePath, 'version-B'); // same 9 bytes as 'version-A'
238
+ fs.utimesSync(filePath, originalStat.atime, originalStat.mtime);
239
+
240
+ // Within maxAge → stale check passes → still serves cached A
241
+ const second = await supertest(server).get('/data.txt').set('Accept-Encoding', 'identity');
242
+ expect(second.text).toBe('version-A');
243
+ });
244
+
245
+ test('after maxAge: cache is refreshed (disk re-read) even when mtime+size unchanged', async () => {
246
+ await new Promise(r => setTimeout(r, 120)); // exceeds 100 ms maxAge
247
+
248
+ const third = await supertest(server).get('/data.txt').set('Accept-Encoding', 'identity');
249
+ expect(third.text).toBe('version-B');
250
+ });
251
+ });
252
+
253
+ describe('serverCache.rawFile.maxAge — disabled (0) preserves previous behaviour', () => {
254
+ let tmpDir;
255
+ let server;
256
+
257
+ beforeAll(() => {
258
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-maxage-off-'));
259
+ fs.writeFileSync(path.join(tmpDir, 'stable.txt'), 'unchanged');
260
+
261
+ const app = new Koa();
262
+ app.use(koaClassicServer(tmpDir, {
263
+ dirListing: { enabled: false },
264
+ serverCache: { rawFile: { enabled: true, maxAge: 0 } }
265
+ }));
266
+ server = app.listen();
267
+ });
268
+
269
+ afterAll(() => {
270
+ server.close();
271
+ fs.rmSync(tmpDir, { recursive: true, force: true });
272
+ });
273
+
274
+ test('cache served indefinitely while mtime+size unchanged', async () => {
275
+ await supertest(server).get('/stable.txt').set('Accept-Encoding', 'identity');
276
+ await new Promise(r => setTimeout(r, 150));
277
+ const res = await supertest(server).get('/stable.txt').set('Accept-Encoding', 'identity');
278
+ expect(res.status).toBe(200);
279
+ expect(res.text).toBe('unchanged');
280
+ });
281
+ });
282
+
283
+ // ─── maxAge: compressedFile staleness ─────────────────────────────────────────
284
+
285
+ describe('serverCache.compressedFile.maxAge — time-based staleness', () => {
286
+ let tmpDir;
287
+ let filePath;
288
+ let server;
289
+
290
+ beforeAll(() => {
291
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-maxage-cmp-'));
292
+ filePath = path.join(tmpDir, 'data.css');
293
+ fs.writeFileSync(filePath, 'a'.repeat(2048)); // above minFileSize=1024 to ensure compression
294
+
295
+ const app = new Koa();
296
+ app.use(koaClassicServer(tmpDir, {
297
+ dirListing: { enabled: false },
298
+ serverCache: { compressedFile: { maxAge: 100 } }
299
+ }));
300
+ server = app.listen();
301
+ });
302
+
303
+ afterAll(() => {
304
+ server.close();
305
+ fs.rmSync(tmpDir, { recursive: true, force: true });
306
+ });
307
+
308
+ test('after maxAge, compressed cache is rebuilt from updated content', async () => {
309
+ const first = await supertest(server).get('/data.css').set('Accept-Encoding', 'gzip');
310
+ expect(first.status).toBe(200);
311
+ expect(first.headers['content-encoding']).toBe('gzip');
312
+ expect(first.text).toBe('a'.repeat(2048));
313
+
314
+ // Replace content with same length, freeze mtime to simulate NFS staleness.
315
+ const originalStat = fs.statSync(filePath);
316
+ fs.writeFileSync(filePath, 'b'.repeat(2048));
317
+ fs.utimesSync(filePath, originalStat.atime, originalStat.mtime);
318
+
319
+ // Within maxAge → cached gzip of A still served.
320
+ const second = await supertest(server).get('/data.css').set('Accept-Encoding', 'gzip');
321
+ expect(second.text).toBe('a'.repeat(2048));
322
+
323
+ // After maxAge → cache refreshed → gzip of B served.
324
+ await new Promise(r => setTimeout(r, 120));
325
+ const third = await supertest(server).get('/data.css').set('Accept-Encoding', 'gzip');
326
+ expect(third.text).toBe('b'.repeat(2048));
327
+ });
328
+ });
329
+
159
330
  // ─── rawFile + Range requests ─────────────────────────────────────────────────
160
331
 
161
332
  describe('serverCache.rawFile — Range request served from buffer', () => {
@@ -194,7 +365,7 @@ describe('serverCache.rawFile + compression — rawFile feeds compressedFile', (
194
365
  let server;
195
366
  beforeAll(() => {
196
367
  server = createApp({
197
- compression: { minSize: false }, // compress small.txt too
368
+ compression: { minFileSize: false }, // compress small.txt too
198
369
  serverCache: { rawFile: { enabled: true } }
199
370
  });
200
371
  });
@@ -276,7 +447,7 @@ describe('serverCache.rawFile — LFU eviction when maxSize exceeded', () => {
276
447
 
277
448
  const app = new Koa();
278
449
  app.use(koaClassicServer(tmpDir, {
279
- showDirContents: false,
450
+ dirListing: { enabled: false },
280
451
  serverCache: {
281
452
  rawFile: {
282
453
  enabled: true,
@@ -321,7 +492,7 @@ describe('serverCache.rawFile — warnInterval throttles warnings', () => {
321
492
 
322
493
  const app = new Koa();
323
494
  app.use(koaClassicServer(tmpDir, {
324
- showDirContents: false,
495
+ dirListing: { enabled: false },
325
496
  serverCache: {
326
497
  rawFile: {
327
498
  enabled: true,
@@ -360,7 +531,7 @@ describe('serverCache.rawFile — buffer passed as 4th param to render function'
360
531
 
361
532
  const app = new Koa();
362
533
  app.use(koaClassicServer(tmpDir, {
363
- showDirContents: false,
534
+ dirListing: { enabled: false },
364
535
  serverCache: { rawFile: { enabled: true } },
365
536
  template: {
366
537
  ext: ['tmpl'],
@@ -404,7 +575,7 @@ describe('serverCache.rawFile — buffer passed as 4th param to render function'
404
575
  let bufferWhenDisabled;
405
576
  const appNoCache = new Koa();
406
577
  appNoCache.use(koaClassicServer(tmpDir, {
407
- showDirContents: false,
578
+ dirListing: { enabled: false },
408
579
  // rawFile.enabled: false by default
409
580
  template: {
410
581
  ext: ['tmpl'],
@@ -124,7 +124,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
124
124
  const app = new Koa();
125
125
  app.use(koaClassicServer(tmpDir, {
126
126
  index: ['index.html'],
127
- showDirContents: true
127
+ dirListing: { enabled: true }
128
128
  }));
129
129
  server = app.listen();
130
130
  request = supertest(server);
@@ -150,7 +150,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
150
150
  const app = new Koa();
151
151
  app.use(koaClassicServer(tmpDir, {
152
152
  index: ['index.ejs'],
153
- showDirContents: true
153
+ dirListing: { enabled: true }
154
154
  }));
155
155
  server = app.listen();
156
156
  request = supertest(server);
@@ -177,7 +177,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
177
177
  const app = new Koa();
178
178
  app.use(koaClassicServer(tmpDir, {
179
179
  index: [],
180
- showDirContents: true
180
+ dirListing: { enabled: true }
181
181
  }));
182
182
  server = app.listen();
183
183
  request = supertest(server);
@@ -210,7 +210,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
210
210
  const app = new Koa();
211
211
  app.use(koaClassicServer(tmpDir, {
212
212
  index: ['index.ejs'],
213
- showDirContents: true,
213
+ dirListing: { enabled: true },
214
214
  template: {
215
215
  ext: ['ejs'],
216
216
  render: async (ctx, next, filePath) => {
@@ -244,7 +244,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
244
244
  const app = new Koa();
245
245
  app.use(koaClassicServer(tmpDir, {
246
246
  index: [],
247
- showDirContents: true
247
+ dirListing: { enabled: true }
248
248
  }));
249
249
  server = app.listen();
250
250
  request = supertest(server);
@@ -275,7 +275,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
275
275
  const app = new Koa();
276
276
  app.use(koaClassicServer(tmpDir, {
277
277
  index: [],
278
- showDirContents: true
278
+ dirListing: { enabled: true }
279
279
  }));
280
280
  server = app.listen();
281
281
  request = supertest(server);
@@ -301,7 +301,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
301
301
  const app = new Koa();
302
302
  app.use(koaClassicServer(tmpDir, {
303
303
  index: [],
304
- showDirContents: true
304
+ dirListing: { enabled: true }
305
305
  }));
306
306
  server = app.listen();
307
307
  request = supertest(server);
@@ -330,7 +330,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
330
330
  const app = new Koa();
331
331
  app.use(koaClassicServer(tmpDir, {
332
332
  index: ['index.html'],
333
- showDirContents: true
333
+ dirListing: { enabled: true }
334
334
  }));
335
335
  server = app.listen();
336
336
  request = supertest(server);
@@ -356,7 +356,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
356
356
  const app = new Koa();
357
357
  app.use(koaClassicServer(tmpDir, {
358
358
  index: [],
359
- showDirContents: true
359
+ dirListing: { enabled: true }
360
360
  }));
361
361
  server = app.listen();
362
362
  request = supertest(server);
@@ -422,7 +422,7 @@ describeIfSymlinks('koa-classic-server - symlink support', () => {
422
422
  const app = new Koa();
423
423
  app.use(koaClassicServer(tmpDir, {
424
424
  index: [/index\.[eE][jJ][sS]/],
425
- showDirContents: true
425
+ dirListing: { enabled: true }
426
426
  }));
427
427
  server = app.listen();
428
428
  request = supertest(server);