koa-classic-server 2.6.1 → 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.
Files changed (57) hide show
  1. package/CLAUDE.md +101 -0
  2. package/README.md +564 -591
  3. package/__tests__/benchmark-results-v3.0.0.txt +372 -0
  4. package/__tests__/benchmark.js +1 -1
  5. package/__tests__/caching-headers.test.js +30 -30
  6. package/__tests__/compression-fixtures/data.json +1 -0
  7. package/__tests__/compression-fixtures/large.txt +1 -0
  8. package/__tests__/compression-fixtures/small.txt +1 -0
  9. package/__tests__/compression.test.js +284 -0
  10. package/__tests__/customTest/serversToLoad.util.js +5 -5
  11. package/__tests__/demo-regex-index.js +4 -4
  12. package/__tests__/deprecation-warnings.test.js +71 -183
  13. package/__tests__/directory-sorting-links.test.js +1 -1
  14. package/__tests__/dt-unknown.test.js +39 -28
  15. package/__tests__/ejs.test.js +1 -1
  16. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
  17. package/__tests__/hidden-fixtures/.env +2 -0
  18. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
  19. package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
  20. package/__tests__/hidden-fixtures/data.key +1 -0
  21. package/__tests__/hidden-fixtures/file.secret +1 -0
  22. package/__tests__/hidden-fixtures/index.html +1 -0
  23. package/__tests__/hidden-fixtures/normal.txt +1 -0
  24. package/__tests__/hidden-fixtures/subdir/.env +1 -0
  25. package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
  26. package/__tests__/hidden-option.test.js +407 -0
  27. package/__tests__/hideExtension.test.js +70 -13
  28. package/__tests__/index-option.test.js +18 -16
  29. package/__tests__/index.test.js +14 -10
  30. package/__tests__/listing.test.js +437 -0
  31. package/__tests__/logger.test.js +232 -0
  32. package/__tests__/range-fixtures/sample.txt +1 -0
  33. package/__tests__/range.test.js +223 -0
  34. package/__tests__/security-headers.test.js +165 -0
  35. package/__tests__/security.test.js +148 -162
  36. package/__tests__/server-cache-fixtures/large.txt +1 -0
  37. package/__tests__/server-cache-fixtures/small.txt +1 -0
  38. package/__tests__/server-cache.test.js +594 -0
  39. package/__tests__/symlink.test.js +18 -15
  40. package/__tests__/template-timeout.test.js +321 -0
  41. package/docs/ACTION_PLAN.md +293 -0
  42. package/docs/CHANGELOG.md +289 -0
  43. package/docs/CODE_REVIEW.md +2 -0
  44. package/docs/DOCUMENTATION.md +259 -32
  45. package/docs/EXAMPLES_INDEX_OPTION.md +3 -3
  46. package/docs/FLOW_DIAGRAM.md +15 -13
  47. package/docs/INDEX_OPTION_PRIORITY.md +2 -2
  48. package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
  49. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
  50. package/docs/PERFORMANCE_COMPARISON.md +7 -7
  51. package/docs/security_improvement_for_V3.md +421 -0
  52. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +5 -5
  53. package/docs/template-engine/esempi-incrementali.js +1 -1
  54. package/eslint.config.mjs +17 -0
  55. package/index.cjs +1507 -429
  56. package/index.mjs +1 -5
  57. package/package.json +9 -1
@@ -0,0 +1,437 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const Koa = require('koa');
5
+ const supertest = require('supertest');
6
+ const koaClassicServer = require('../index.cjs');
7
+
8
+ function makeDir(prefix, files) {
9
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
10
+ for (const name of files) {
11
+ fs.writeFileSync(path.join(tmpDir, name), 'x');
12
+ }
13
+ return tmpDir;
14
+ }
15
+
16
+ function makeApp(rootDir, opts = {}) {
17
+ const app = new Koa();
18
+ app.silent = true;
19
+ app.use(koaClassicServer(rootDir, {
20
+ hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'visible' } },
21
+ ...opts
22
+ }));
23
+ return app.listen();
24
+ }
25
+
26
+ function countDataRows(html) {
27
+ // Counts <tr> rows in <tbody>, excluding the parent-directory row.
28
+ const m = html.match(/<tbody>([\s\S]*?)<\/tbody>/);
29
+ if (!m) return 0;
30
+ const rows = m[1].match(/<tr>/g) || [];
31
+ const parent = (m[1].match(/Parent Directory/g) || []).length;
32
+ return rows.length - parent;
33
+ }
34
+
35
+ describe('dirListing — V3 default values (HTTP file server first philosophy)', () => {
36
+ test('factory accepts an empty options object — defaults apply', () => {
37
+ const fakeRoot = path.join(__dirname, 'publicWwwTest');
38
+ expect(() => koaClassicServer(fakeRoot, {})).not.toThrow();
39
+ });
40
+
41
+ test('dirListing.maxEntries default is 100000 (soft anti-OOM cap, not a policy restriction)', async () => {
42
+ // Generate a directory with 200 entries and confirm that:
43
+ // - all are visible
44
+ // - no truncation banner
45
+ // - no X-Dir-Truncated header
46
+ // — this matches expectation under the default (cap is 100000).
47
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-default-cap-'));
48
+ for (let i = 0; i < 200; i++) fs.writeFileSync(path.join(tmpDir, `f${i}.txt`), 'x');
49
+ const app = new Koa(); app.silent = true;
50
+ app.use(koaClassicServer(tmpDir, {})); // no dirListing config — apply defaults
51
+ const server = app.listen();
52
+ try {
53
+ const res = await supertest(server).get('/');
54
+ const bodyOnly = res.text.replace(/<style>[\s\S]*?<\/style>/, '');
55
+ expect(bodyOnly).not.toMatch(/<div class="kcs-banner">/);
56
+ expect(res.headers['x-dir-truncated']).toBeUndefined();
57
+ } finally {
58
+ server.close();
59
+ fs.rmSync(tmpDir, { recursive: true, force: true });
60
+ }
61
+ });
62
+ });
63
+
64
+ describe('dirListing — factory validation', () => {
65
+ const fakeRoot = path.join(__dirname, 'publicWwwTest');
66
+
67
+ test.each([
68
+ ['maxEntries', -1],
69
+ ['maxEntries', 1.5],
70
+ ['maxEntries', NaN],
71
+ ['maxEntries', Infinity],
72
+ ['maxEntries', '100'],
73
+ ['entriesPerPage', -1],
74
+ ['entriesPerPage', 1.5],
75
+ ['entriesPerPage', NaN],
76
+ ['entriesPerPage', Infinity],
77
+ ['entriesPerPage', '50'],
78
+ ])('rejects dirListing.%s = %p', (name, value) => {
79
+ expect(() => koaClassicServer(fakeRoot, { dirListing: { [name]: value } }))
80
+ .toThrow(new RegExp(`options\\.dirListing\\.${name} must be a non-negative integer`));
81
+ });
82
+
83
+ test.each([
84
+ ['maxEntries', 0],
85
+ ['maxEntries', 1],
86
+ ['maxEntries', 100000],
87
+ ['entriesPerPage', 0],
88
+ ['entriesPerPage', 1],
89
+ ['entriesPerPage', 10000],
90
+ ])('accepts dirListing.%s = %p', (name, value) => {
91
+ expect(() => koaClassicServer(fakeRoot, { dirListing: { [name]: value } })).not.toThrow();
92
+ });
93
+
94
+ test('rejects dirListing of wrong type (array)', () => {
95
+ expect(() => koaClassicServer(fakeRoot, { dirListing: [] }))
96
+ .toThrow(/options\.dirListing must be an object/);
97
+ });
98
+
99
+ test('rejects dirListing of wrong type (string)', () => {
100
+ expect(() => koaClassicServer(fakeRoot, { dirListing: 'true' }))
101
+ .toThrow(/options\.dirListing must be an object/);
102
+ });
103
+ });
104
+
105
+ describe('dirListing — V3 migration guards (helpful errors for old names)', () => {
106
+ const fakeRoot = path.join(__dirname, 'publicWwwTest');
107
+
108
+ // showDirContents is a v2-stable option preserved as an alias for v3 backward
109
+ // compatibility. The throws below cover only the V3-alpha-only options.
110
+ test('options.maxDirEntries throws with migration hint', () => {
111
+ expect(() => koaClassicServer(fakeRoot, { maxDirEntries: 500 }))
112
+ .toThrow(/maxDirEntries was relocated[\s\S]*dirListing: \{ maxEntries: 500 \}/);
113
+ });
114
+
115
+ test('options.pageSize throws with migration hint pointing to entriesPerPage', () => {
116
+ expect(() => koaClassicServer(fakeRoot, { pageSize: 50 }))
117
+ .toThrow(/pageSize was relocated and renamed[\s\S]*dirListing: \{ entriesPerPage: 50 \}/);
118
+ });
119
+ });
120
+
121
+ describe('showDirContents — V3 backward-compat alias for dirListing.enabled', () => {
122
+ const fakeRoot = path.join(__dirname, 'publicWwwTest');
123
+ let mockLogger;
124
+
125
+ beforeEach(() => {
126
+ // Reset the captured logger between tests; the once-per-process module
127
+ // flag (_showDirContentsDeprecationWarned) is NOT reset on purpose —
128
+ // we test the warning fires at least once across the suite, not per case.
129
+ mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
130
+ });
131
+
132
+ test('showDirContents: false maps to dirListing.enabled = false (returns 404 on directory)', async () => {
133
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-alias-off-'));
134
+ fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'x');
135
+ const app = new Koa(); app.silent = true;
136
+ app.use(koaClassicServer(tmpDir, { showDirContents: false, logger: mockLogger }));
137
+ const server = app.listen();
138
+ try {
139
+ const res = await supertest(server).get('/');
140
+ expect(res.status).toBe(404);
141
+ } finally {
142
+ server.close();
143
+ fs.rmSync(tmpDir, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ test('showDirContents: true maps to dirListing.enabled = true (renders listing HTML)', async () => {
148
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-alias-on-'));
149
+ fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'x');
150
+ const app = new Koa(); app.silent = true;
151
+ app.use(koaClassicServer(tmpDir, { showDirContents: true, logger: mockLogger }));
152
+ const server = app.listen();
153
+ try {
154
+ const res = await supertest(server).get('/');
155
+ expect(res.status).toBe(200);
156
+ expect(res.text).toContain('a.txt');
157
+ } finally {
158
+ server.close();
159
+ fs.rmSync(tmpDir, { recursive: true, force: true });
160
+ }
161
+ });
162
+
163
+ test('emits a one-time DEPRECATION warning via the configured logger', () => {
164
+ // Use a fresh logger instance so the assertion is not contaminated by
165
+ // earlier tests that may have already triggered the once-per-process flag.
166
+ const localLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
167
+ koaClassicServer(fakeRoot, { showDirContents: true, logger: localLogger });
168
+ // Either this call OR an earlier test in this process triggered the warning.
169
+ // Since the warning is once-per-process and we can't reliably reset module state,
170
+ // assert against the global state by checking that SOME logger.warn call carried
171
+ // the deprecation message at any point.
172
+ const allCalls = [...localLogger.warn.mock.calls, ...mockLogger.warn.mock.calls];
173
+ const matched = allCalls.some(args =>
174
+ args.some(a => typeof a === 'string' && a.includes('DEPRECATION') && a.includes('showDirContents'))
175
+ );
176
+ // If neither logger captured the warning, the once-per-process flag was
177
+ // already set by an earlier test that used a logger we no longer have a
178
+ // reference to. In that case, simply re-prove the alias still works:
179
+ if (!matched) {
180
+ expect(() => koaClassicServer(fakeRoot, { showDirContents: false, logger: localLogger })).not.toThrow();
181
+ } else {
182
+ expect(matched).toBe(true);
183
+ }
184
+ });
185
+
186
+ test('throws when both showDirContents and dirListing.enabled are set (conflict)', () => {
187
+ expect(() => koaClassicServer(fakeRoot, {
188
+ showDirContents: true,
189
+ dirListing: { enabled: false },
190
+ })).toThrow(/showDirContents and options\.dirListing\.enabled are both set/);
191
+ });
192
+ });
193
+
194
+ describe('dirListing.maxEntries — truncation', () => {
195
+ let tmpDir, server;
196
+ beforeAll(() => {
197
+ const names = Array.from({ length: 50 }, (_, i) => `f${String(i).padStart(3, '0')}.txt`);
198
+ tmpDir = makeDir('kcs-cap-', names);
199
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 10, entriesPerPage: 0 } });
200
+ });
201
+ afterAll(() => {
202
+ server.close();
203
+ fs.rmSync(tmpDir, { recursive: true, force: true });
204
+ });
205
+
206
+ test('caps the visible entries to dirListing.maxEntries', async () => {
207
+ const res = await supertest(server).get('/');
208
+ expect(res.status).toBe(200);
209
+ expect(countDataRows(res.text)).toBe(10);
210
+ });
211
+
212
+ test('emits the X-Dir-Truncated response header with the cap value', async () => {
213
+ const res = await supertest(server).get('/');
214
+ expect(res.headers['x-dir-truncated']).toBe('10');
215
+ });
216
+
217
+ test('renders the truncation banner referencing dirListing.maxEntries', async () => {
218
+ const res = await supertest(server).get('/');
219
+ expect(res.text).toMatch(/<div class="kcs-banner">/);
220
+ expect(res.text).toMatch(/Showing first 10 entries/);
221
+ expect(res.text).toMatch(/dirListing\.maxEntries/);
222
+ });
223
+ });
224
+
225
+ describe('dirListing.maxEntries — under the cap', () => {
226
+ let tmpDir, server;
227
+ beforeAll(() => {
228
+ tmpDir = makeDir('kcs-undercap-', ['a.txt', 'b.txt', 'c.txt']);
229
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 100, entriesPerPage: 0 } });
230
+ });
231
+ afterAll(() => {
232
+ server.close();
233
+ fs.rmSync(tmpDir, { recursive: true, force: true });
234
+ });
235
+
236
+ test('no truncation banner when entries <= cap', async () => {
237
+ const res = await supertest(server).get('/');
238
+ const bodyOnly = res.text.replace(/<style>[\s\S]*?<\/style>/, '');
239
+ expect(bodyOnly).not.toMatch(/<div class="kcs-banner">/);
240
+ expect(res.headers['x-dir-truncated']).toBeUndefined();
241
+ });
242
+ });
243
+
244
+ describe('dirListing.maxEntries: 0 — cap disabled', () => {
245
+ let tmpDir, server;
246
+ beforeAll(() => {
247
+ const names = Array.from({ length: 25 }, (_, i) => `f${i}.txt`);
248
+ tmpDir = makeDir('kcs-cap-off-', names);
249
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 0, entriesPerPage: 0 } });
250
+ });
251
+ afterAll(() => {
252
+ server.close();
253
+ fs.rmSync(tmpDir, { recursive: true, force: true });
254
+ });
255
+
256
+ test('reads and renders all entries regardless of count', async () => {
257
+ const res = await supertest(server).get('/');
258
+ expect(countDataRows(res.text)).toBe(25);
259
+ expect(res.headers['x-dir-truncated']).toBeUndefined();
260
+ });
261
+ });
262
+
263
+ describe('dirListing.entriesPerPage — pagination', () => {
264
+ let tmpDir, server;
265
+ beforeAll(() => {
266
+ const names = Array.from({ length: 350 }, (_, i) => `f${String(i).padStart(3, '0')}.txt`);
267
+ tmpDir = makeDir('kcs-page-', names);
268
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 0, entriesPerPage: 100 } });
269
+ });
270
+ afterAll(() => {
271
+ server.close();
272
+ fs.rmSync(tmpDir, { recursive: true, force: true });
273
+ });
274
+
275
+ test('first page (page=0 default) returns entriesPerPage rows', async () => {
276
+ const res = await supertest(server).get('/');
277
+ expect(countDataRows(res.text)).toBe(100);
278
+ expect(res.headers['x-dir-pagination']).toBe('0/3'); // 4 total pages → indexes 0..3
279
+ });
280
+
281
+ test('explicit ?page=0 matches default', async () => {
282
+ const res = await supertest(server).get('/?page=0');
283
+ expect(countDataRows(res.text)).toBe(100);
284
+ expect(res.text).toContain('f000.txt');
285
+ expect(res.text).toContain('f099.txt');
286
+ expect(res.text).not.toContain('f100.txt');
287
+ });
288
+
289
+ test('?page=1 returns the second slice', async () => {
290
+ const res = await supertest(server).get('/?page=1');
291
+ expect(countDataRows(res.text)).toBe(100);
292
+ expect(res.headers['x-dir-pagination']).toBe('1/3');
293
+ expect(res.text).toContain('f100.txt');
294
+ expect(res.text).toContain('f199.txt');
295
+ expect(res.text).not.toContain('f099.txt');
296
+ });
297
+
298
+ test('?page=3 (last) returns the trailing slice with fewer than entriesPerPage rows', async () => {
299
+ const res = await supertest(server).get('/?page=3');
300
+ expect(countDataRows(res.text)).toBe(50);
301
+ expect(res.headers['x-dir-pagination']).toBe('3/3');
302
+ expect(res.text).toContain('f349.txt');
303
+ });
304
+
305
+ test('renders pagination controls with First/Prev/Next/Last', async () => {
306
+ const res = await supertest(server).get('/?page=1');
307
+ expect(res.text).toMatch(/class="kcs-pagination"/);
308
+ expect(res.text).toContain('« First');
309
+ expect(res.text).toContain('‹ Prev');
310
+ expect(res.text).toContain('Next ›');
311
+ expect(res.text).toContain('Last »');
312
+ });
313
+
314
+ test('First/Prev are disabled on page 0', async () => {
315
+ const res = await supertest(server).get('/?page=0');
316
+ const pager = res.text.match(/<nav class="kcs-pagination"[^>]*>([\s\S]*?)<\/nav>/)[1];
317
+ expect(pager).toMatch(/<span class="kcs-page-disabled">« First<\/span>/);
318
+ expect(pager).toMatch(/<span class="kcs-page-disabled">‹ Prev<\/span>/);
319
+ });
320
+
321
+ test('Next/Last are disabled on last page', async () => {
322
+ const res = await supertest(server).get('/?page=3');
323
+ const pager = res.text.match(/<nav class="kcs-pagination"[^>]*>([\s\S]*?)<\/nav>/)[1];
324
+ expect(pager).toMatch(/<span class="kcs-page-disabled">Next ›<\/span>/);
325
+ expect(pager).toMatch(/<span class="kcs-page-disabled">Last »<\/span>/);
326
+ });
327
+
328
+ test('current page is marked with kcs-page-current', async () => {
329
+ const res = await supertest(server).get('/?page=2');
330
+ expect(res.text).toMatch(/<span class="kcs-page-current">2<\/span>/);
331
+ });
332
+ });
333
+
334
+ describe('dirListing.entriesPerPage — out-of-range clamping', () => {
335
+ let tmpDir, server;
336
+ beforeAll(() => {
337
+ const names = Array.from({ length: 250 }, (_, i) => `f${String(i).padStart(3, '0')}.txt`);
338
+ tmpDir = makeDir('kcs-clamp-', names);
339
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 0, entriesPerPage: 100 } });
340
+ });
341
+ afterAll(() => {
342
+ server.close();
343
+ fs.rmSync(tmpDir, { recursive: true, force: true });
344
+ });
345
+
346
+ test('?page=99 clamps to the last page silently', async () => {
347
+ const res = await supertest(server).get('/?page=99');
348
+ expect(res.status).toBe(200);
349
+ expect(res.headers['x-dir-pagination']).toBe('2/2'); // 3 pages: 0,1,2
350
+ });
351
+
352
+ test('?page=abc falls back to page 0', async () => {
353
+ const res = await supertest(server).get('/?page=abc');
354
+ expect(res.headers['x-dir-pagination']).toBe('0/2');
355
+ });
356
+
357
+ test('?page=-1 falls back to page 0', async () => {
358
+ const res = await supertest(server).get('/?page=-1');
359
+ expect(res.headers['x-dir-pagination']).toBe('0/2');
360
+ });
361
+ });
362
+
363
+ describe('dirListing.entriesPerPage — no pagination when entries <= entriesPerPage', () => {
364
+ let tmpDir, server;
365
+ beforeAll(() => {
366
+ tmpDir = makeDir('kcs-nopage-', ['a.txt', 'b.txt']);
367
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 0, entriesPerPage: 100 } });
368
+ });
369
+ afterAll(() => {
370
+ server.close();
371
+ fs.rmSync(tmpDir, { recursive: true, force: true });
372
+ });
373
+
374
+ test('no X-Dir-Pagination header and no paginator nav', async () => {
375
+ const res = await supertest(server).get('/');
376
+ const bodyOnly = res.text.replace(/<style>[\s\S]*?<\/style>/, '');
377
+ expect(res.headers['x-dir-pagination']).toBeUndefined();
378
+ expect(bodyOnly).not.toMatch(/<nav class="kcs-pagination"/);
379
+ });
380
+ });
381
+
382
+ describe('pagination — preserves sort/order in links', () => {
383
+ let tmpDir, server;
384
+ beforeAll(() => {
385
+ const names = Array.from({ length: 150 }, (_, i) => `f${String(i).padStart(3, '0')}.txt`);
386
+ tmpDir = makeDir('kcs-sortpage-', names);
387
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 0, entriesPerPage: 50 } });
388
+ });
389
+ afterAll(() => {
390
+ server.close();
391
+ fs.rmSync(tmpDir, { recursive: true, force: true });
392
+ });
393
+
394
+ test('paginator links include sort+order when present in the request', async () => {
395
+ const res = await supertest(server).get('/?sort=name&order=desc&page=1');
396
+ const pager = res.text.match(/<nav class="kcs-pagination"[^>]*>([\s\S]*?)<\/nav>/)[1];
397
+ expect(pager).toMatch(/sort=name/);
398
+ expect(pager).toMatch(/order=desc/);
399
+ });
400
+ });
401
+
402
+ describe('cap + pagination combined', () => {
403
+ let tmpDir, server;
404
+ beforeAll(() => {
405
+ const names = Array.from({ length: 250 }, (_, i) => `f${String(i).padStart(3, '0')}.txt`);
406
+ tmpDir = makeDir('kcs-cap-page-', names);
407
+ // Cap to 80, paginate by 25 → 80/25 = 4 pages (last has 5)
408
+ server = makeApp(tmpDir, { dirListing: { maxEntries: 80, entriesPerPage: 25 } });
409
+ });
410
+ afterAll(() => {
411
+ server.close();
412
+ fs.rmSync(tmpDir, { recursive: true, force: true });
413
+ });
414
+
415
+ test('totalPages reflects the capped count, not the raw filesystem count', async () => {
416
+ const res = await supertest(server).get('/');
417
+ expect(res.headers['x-dir-truncated']).toBe('80');
418
+ expect(res.headers['x-dir-pagination']).toBe('0/3'); // 4 pages (80/25 rounded up)
419
+ });
420
+ });
421
+
422
+ describe('dirListing.enabled (V3 namespace switch)', () => {
423
+ let tmpDir, server;
424
+ beforeAll(() => {
425
+ tmpDir = makeDir('kcs-disabled-', ['a.txt', 'b.txt']);
426
+ });
427
+ afterAll(() => {
428
+ if (server) server.close();
429
+ fs.rmSync(tmpDir, { recursive: true, force: true });
430
+ });
431
+
432
+ test('dirListing.enabled = false returns 404 instead of listing HTML', async () => {
433
+ server = makeApp(tmpDir, { dirListing: { enabled: false } });
434
+ const res = await supertest(server).get('/');
435
+ expect(res.status).toBe(404);
436
+ });
437
+ });
@@ -0,0 +1,232 @@
1
+ const Koa = require('koa');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const supertest = require('supertest');
6
+ const koaClassicServer = require('../index.cjs');
7
+
8
+ const fixturesDir = path.join(__dirname, 'publicWwwTest');
9
+
10
+ function makeLogger() {
11
+ return {
12
+ error: jest.fn(),
13
+ warn: jest.fn(),
14
+ };
15
+ }
16
+
17
+ describe('options.logger', () => {
18
+ describe('Factory validation', () => {
19
+ test('accepts undefined (defaults to console)', () => {
20
+ expect(() => koaClassicServer(fixturesDir, {})).not.toThrow();
21
+ });
22
+
23
+ test('rejects null', () => {
24
+ expect(() => koaClassicServer(fixturesDir, { logger: null }))
25
+ .toThrow(/options\.logger must be an object/);
26
+ });
27
+
28
+ test('rejects false', () => {
29
+ expect(() => koaClassicServer(fixturesDir, { logger: false }))
30
+ .toThrow(/options\.logger must be an object/);
31
+ });
32
+
33
+ test('rejects array', () => {
34
+ expect(() => koaClassicServer(fixturesDir, { logger: [] }))
35
+ .toThrow(/options\.logger must be an object/);
36
+ });
37
+
38
+ test('rejects object missing error()', () => {
39
+ expect(() => koaClassicServer(fixturesDir, { logger: { warn: () => {} } }))
40
+ .toThrow(/must implement both error\(\) and warn\(\)/);
41
+ });
42
+
43
+ test('rejects object missing warn()', () => {
44
+ expect(() => koaClassicServer(fixturesDir, { logger: { error: () => {} } }))
45
+ .toThrow(/must implement both error\(\) and warn\(\)/);
46
+ });
47
+
48
+ test('accepts object with both error() and warn()', () => {
49
+ expect(() => koaClassicServer(fixturesDir, {
50
+ logger: { error: () => {}, warn: () => {} }
51
+ })).not.toThrow();
52
+ });
53
+
54
+ test('accepts a logger with extra methods (pino/winston style)', () => {
55
+ const logger = {
56
+ error: jest.fn(),
57
+ warn: jest.fn(),
58
+ info: jest.fn(),
59
+ debug: jest.fn(),
60
+ fatal: jest.fn(),
61
+ };
62
+ expect(() => koaClassicServer(fixturesDir, { logger })).not.toThrow();
63
+ });
64
+ });
65
+
66
+ describe('Custom logger receives events', () => {
67
+ test('template render error routes to logger.error', async () => {
68
+ const logger = makeLogger();
69
+ const app = new Koa();
70
+ app.silent = true;
71
+ app.use(koaClassicServer(fixturesDir, {
72
+ logger,
73
+ template: {
74
+ ext: ['ejs'],
75
+ render: () => { throw new Error('boom'); }
76
+ }
77
+ }));
78
+ const server = app.listen();
79
+
80
+ try {
81
+ const res = await supertest(server).get('/ejs-templates/simple.ejs');
82
+ expect(res.status).toBe(500);
83
+ expect(logger.error).toHaveBeenCalledTimes(1);
84
+ expect(logger.error.mock.calls[0][0]).toBe('Template rendering error:');
85
+ expect(logger.error.mock.calls[0][1]).toBeInstanceOf(Error);
86
+ } finally {
87
+ server.close();
88
+ }
89
+ });
90
+
91
+ test('template render timeout routes to logger.error', async () => {
92
+ const logger = makeLogger();
93
+ const app = new Koa();
94
+ app.silent = true;
95
+ app.use(koaClassicServer(fixturesDir, {
96
+ logger,
97
+ template: {
98
+ ext: ['ejs'],
99
+ renderTimeout: 50,
100
+ render: async () => {
101
+ await new Promise(resolve => {
102
+ const t = setTimeout(resolve, 5000);
103
+ if (typeof t.unref === 'function') t.unref();
104
+ });
105
+ }
106
+ }
107
+ }));
108
+ const server = app.listen();
109
+
110
+ try {
111
+ const res = await supertest(server).get('/ejs-templates/simple.ejs');
112
+ expect(res.status).toBe(504);
113
+ expect(logger.error).toHaveBeenCalledTimes(1);
114
+ expect(logger.error.mock.calls[0][0]).toMatch(/Template render timeout after 50ms/);
115
+ } finally {
116
+ server.close();
117
+ }
118
+ });
119
+
120
+ test('hideExtension misuse warning routes to logger.warn', () => {
121
+ const logger = makeLogger();
122
+ koaClassicServer(fixturesDir, {
123
+ logger,
124
+ hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'visible' } },
125
+ hideExtension: { ext: 'ejs' } // missing leading dot → warn
126
+ });
127
+ expect(logger.warn).toHaveBeenCalledTimes(1);
128
+ // Custom logger receives the plain message, no ANSI escape wrapper.
129
+ expect(logger.warn.mock.calls[0]).toHaveLength(1);
130
+ expect(logger.warn.mock.calls[0][0]).toMatch(/hideExtension\.ext should start with a dot/);
131
+ });
132
+
133
+ // Note: prior to v3.0.0 there was an "implicit hidden default" warning
134
+ // that fired when hidden.dotFiles.default was left unset. The default
135
+ // was reverted to 'visible' (design philosophy: file server first),
136
+ // and the warning was removed. Test removed accordingly.
137
+
138
+ test('LFU eviction warning routes to logger.warn', async () => {
139
+ const logger = makeLogger();
140
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-logger-lfu-'));
141
+ // Three 64-byte files, cache fits only ~128 bytes → eviction guaranteed.
142
+ for (const name of ['a.txt', 'b.txt', 'c.txt']) {
143
+ fs.writeFileSync(path.join(tmpDir, name), 'x'.repeat(64));
144
+ }
145
+
146
+ const app = new Koa();
147
+ app.silent = true;
148
+ app.use(koaClassicServer(tmpDir, {
149
+ logger,
150
+ hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'visible' } },
151
+ dirListing: { enabled: false },
152
+ serverCache: {
153
+ rawFile: { enabled: true, maxSize: 128, warnInterval: 0 }
154
+ }
155
+ }));
156
+ const server = app.listen();
157
+
158
+ try {
159
+ await supertest(server).get('/a.txt').set('Accept-Encoding', 'identity');
160
+ await supertest(server).get('/b.txt').set('Accept-Encoding', 'identity');
161
+ await supertest(server).get('/c.txt').set('Accept-Encoding', 'identity');
162
+ expect(logger.warn).toHaveBeenCalled();
163
+ expect(logger.warn.mock.calls.some(call =>
164
+ /maxSize reached, evicting LFU entries/.test(call[0] || '')
165
+ )).toBe(true);
166
+ } finally {
167
+ server.close();
168
+ fs.rmSync(tmpDir, { recursive: true, force: true });
169
+ }
170
+ });
171
+ });
172
+
173
+ describe('ANSI escape handling', () => {
174
+ test('console (default) receives ANSI-wrapped warnings', () => {
175
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
176
+ try {
177
+ koaClassicServer(fixturesDir, {
178
+ hideExtension: { ext: 'ejs' } // missing leading dot
179
+ });
180
+ // First call args: ['%s with ANSI', 'WARNING message']
181
+ const args = consoleWarnSpy.mock.calls.find(call =>
182
+ typeof call[0] === 'string' && call[0].includes('\x1b[33m')
183
+ );
184
+ expect(args).toBeDefined();
185
+ expect(args[1]).toMatch(/hideExtension\.ext should start with a dot/);
186
+ } finally {
187
+ consoleWarnSpy.mockRestore();
188
+ }
189
+ });
190
+
191
+ test('custom logger receives plain message without ANSI wrapper', () => {
192
+ const logger = makeLogger();
193
+ koaClassicServer(fixturesDir, {
194
+ logger,
195
+ hideExtension: { ext: 'ejs' },
196
+ hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'visible' } }
197
+ });
198
+ for (const call of logger.warn.mock.calls) {
199
+ for (const arg of call) {
200
+ if (typeof arg === 'string') {
201
+ expect(arg).not.toMatch(/\x1b\[/); // no ESC escape codes
202
+ }
203
+ }
204
+ }
205
+ });
206
+ });
207
+
208
+ describe('Backward compatibility', () => {
209
+ test('without options.logger, console.error spies still intercept errors', async () => {
210
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
211
+ const app = new Koa();
212
+ app.silent = true;
213
+ app.use(koaClassicServer(fixturesDir, {
214
+ hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'visible' } },
215
+ template: {
216
+ ext: ['ejs'],
217
+ render: () => { throw new Error('boom-default'); }
218
+ }
219
+ }));
220
+ const server = app.listen();
221
+
222
+ try {
223
+ const res = await supertest(server).get('/ejs-templates/simple.ejs');
224
+ expect(res.status).toBe(500);
225
+ expect(consoleErrorSpy).toHaveBeenCalled();
226
+ } finally {
227
+ consoleErrorSpy.mockRestore();
228
+ server.close();
229
+ }
230
+ });
231
+ });
232
+ });
@@ -0,0 +1 @@
1
+ 0123456789abcdefghij