koa-classic-server 3.0.0 → 3.1.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 (105) hide show
  1. package/README.md +44 -120
  2. package/index.cjs +236 -21
  3. package/package.json +10 -5
  4. package/.github/workflows/npm-publish.yml +0 -98
  5. package/CLAUDE.md +0 -101
  6. package/__tests__/benchmark-results-baseline-v1.2.0.txt +0 -354
  7. package/__tests__/benchmark-results-optimized-v2.0.0.txt +0 -354
  8. package/__tests__/benchmark-results-v3.0.0.txt +0 -372
  9. package/__tests__/benchmark.js +0 -239
  10. package/__tests__/caching-headers.test.js +0 -556
  11. package/__tests__/compression-fixtures/data.json +0 -1
  12. package/__tests__/compression-fixtures/large.txt +0 -1
  13. package/__tests__/compression-fixtures/small.txt +0 -1
  14. package/__tests__/compression.test.js +0 -284
  15. package/__tests__/customTest/README.md +0 -6
  16. package/__tests__/customTest/loadConfig.util.js +0 -41
  17. package/__tests__/customTest/serversToLoad.util.js +0 -93
  18. package/__tests__/demo-regex-index.js +0 -140
  19. package/__tests__/deprecation-warnings.test.js +0 -105
  20. package/__tests__/directory-sorting-links.test.js +0 -135
  21. package/__tests__/dt-unknown.test.js +0 -635
  22. package/__tests__/ejs.test.js +0 -299
  23. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +0 -1
  24. package/__tests__/hidden-fixtures/.env +0 -2
  25. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +0 -1
  26. package/__tests__/hidden-fixtures/config/secrets/password.txt +0 -1
  27. package/__tests__/hidden-fixtures/data.key +0 -1
  28. package/__tests__/hidden-fixtures/file.secret +0 -1
  29. package/__tests__/hidden-fixtures/index.html +0 -1
  30. package/__tests__/hidden-fixtures/normal.txt +0 -1
  31. package/__tests__/hidden-fixtures/subdir/.env +0 -1
  32. package/__tests__/hidden-fixtures/subdir/regular.txt +0 -1
  33. package/__tests__/hidden-option.test.js +0 -407
  34. package/__tests__/hideExtension.test.js +0 -607
  35. package/__tests__/index-option.test.js +0 -449
  36. package/__tests__/index.test.js +0 -345
  37. package/__tests__/listing.test.js +0 -437
  38. package/__tests__/logger.test.js +0 -232
  39. package/__tests__/performance.test.js +0 -370
  40. package/__tests__/publicWwwTest/cartella/sottocartella/ciao.html +0 -11
  41. package/__tests__/publicWwwTest/cartella/sottocartella/provaEjs/testEjs.ejs +0 -11
  42. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome/file con spazio nel nome .txt +0 -1
  43. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome /file con spazio nel nome .txt +0 -1
  44. package/__tests__/publicWwwTest/ejs-templates/complex.ejs +0 -56
  45. package/__tests__/publicWwwTest/ejs-templates/index.ejs +0 -30
  46. package/__tests__/publicWwwTest/ejs-templates/simple.ejs +0 -13
  47. package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +0 -28
  48. package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +0 -26
  49. package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +0 -16
  50. package/__tests__/publicWwwTest/hideext-test/about/index.html +0 -1
  51. package/__tests__/publicWwwTest/hideext-test/about.EJS +0 -1
  52. package/__tests__/publicWwwTest/hideext-test/about.ejs +0 -2
  53. package/__tests__/publicWwwTest/hideext-test/blog/articolo.ejs +0 -2
  54. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina +0 -1
  55. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina.ejs +0 -1
  56. package/__tests__/publicWwwTest/hideext-test/file.ejs.bak +0 -1
  57. package/__tests__/publicWwwTest/hideext-test/index.ejs +0 -2
  58. package/__tests__/publicWwwTest/hideext-test/photo.txt +0 -1
  59. package/__tests__/publicWwwTest/hideext-test/sezione/index.ejs +0 -1
  60. package/__tests__/publicWwwTest/hideext-test/style.css +0 -1
  61. package/__tests__/publicWwwTest/ile_vuoto.txt +0 -0
  62. package/__tests__/publicWwwTest/index.html +0 -11
  63. package/__tests__/publicWwwTest/prova file .txt +0 -2
  64. package/__tests__/publicWwwTest/semplicetxt.txt +0 -1
  65. package/__tests__/publicWwwTest/test-page.html +0 -1
  66. package/__tests__/publicWwwTest/test.txt +0 -1
  67. package/__tests__/range-fixtures/sample.txt +0 -1
  68. package/__tests__/range.test.js +0 -223
  69. package/__tests__/security-headers.test.js +0 -165
  70. package/__tests__/security.test.js +0 -322
  71. package/__tests__/server-cache-fixtures/large.txt +0 -1
  72. package/__tests__/server-cache-fixtures/small.txt +0 -1
  73. package/__tests__/server-cache.test.js +0 -594
  74. package/__tests__/setup-benchmark.js +0 -178
  75. package/__tests__/symlink.test.js +0 -441
  76. package/__tests__/template-timeout.test.js +0 -321
  77. package/__tests__/test-regex-quick.js +0 -158
  78. package/__tests__/useOriginalUrl.test.js +0 -213
  79. package/docs/ACTION_PLAN.md +0 -293
  80. package/docs/BENCHMARKS.md +0 -317
  81. package/docs/CHANGELOG.md +0 -880
  82. package/docs/CODE_REVIEW.md +0 -300
  83. package/docs/DEBUG_REPORT.md +0 -593
  84. package/docs/DOCUMENTATION.md +0 -1864
  85. package/docs/EXAMPLES_INDEX_OPTION.md +0 -395
  86. package/docs/FLOW_DIAGRAM.md +0 -954
  87. package/docs/INDEX_OPTION_PRIORITY.md +0 -527
  88. package/docs/OPTIMIZATION_HTTP_CACHING.md +0 -689
  89. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +0 -864
  90. package/docs/PERFORMANCE_ANALYSIS.md +0 -839
  91. package/docs/PERFORMANCE_COMPARISON.md +0 -388
  92. package/docs/noteExports.md +0 -148
  93. package/docs/security_improvement_for_V3.md +0 -421
  94. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +0 -1734
  95. package/docs/template-engine/esempi-incrementali.js +0 -192
  96. package/docs/template-engine/examples/esempio1-nessun-dato.ejs +0 -12
  97. package/docs/template-engine/examples/esempio2-una-variabile.ejs +0 -11
  98. package/docs/template-engine/examples/esempio3-piu-variabili.ejs +0 -15
  99. package/docs/template-engine/examples/esempio4-condizionale.ejs +0 -18
  100. package/docs/template-engine/examples/esempio5-loop.ejs +0 -18
  101. package/docs/template-engine/examples/index-esempi.html +0 -181
  102. package/docs/template-engine/examples/index.html +0 -40
  103. package/docs/template-engine/examples/test.ejs +0 -64
  104. package/eslint.config.mjs +0 -17
  105. package/jest.config.js +0 -18
@@ -1,607 +0,0 @@
1
- //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
2
- //
3
- // TEST FOR hideExtension OPTION
4
- // This test verifies that the hideExtension option works correctly:
5
- // - Clean URL resolution (URL without extension → file with extension)
6
- // - Redirect from URL with extension to clean URL
7
- // - Query string preservation
8
- // - Conflict resolution (directory vs file, extensionless vs extension)
9
- // - Input validation
10
- // - Interaction with existing options (urlsReserved, useOriginalUrl, template)
11
- //
12
- //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
13
-
14
- const supertest = require('supertest');
15
- const koaClassicServer = require('../index.cjs');
16
- const Koa = require('koa');
17
- const fs = require('fs');
18
- const path = require('path');
19
- const ejs = require('ejs');
20
-
21
- const rootDir = path.join(__dirname, 'publicWwwTest', 'hideext-test');
22
-
23
- describe('hideExtension option tests', () => {
24
-
25
- // ==========================================
26
- // Clean URL Resolution
27
- // ==========================================
28
- describe('Clean URL resolution', () => {
29
- let app, server, request;
30
-
31
- beforeAll(() => {
32
- app = new Koa();
33
- app.use(koaClassicServer(rootDir, {
34
- dirListing: { enabled: true },
35
- index: ['index.ejs'],
36
- hideExtension: { ext: '.ejs' },
37
- template: {
38
- ext: ['ejs'],
39
- render: async (ctx, next, filePath) => {
40
- const content = await fs.promises.readFile(filePath, 'utf-8');
41
- ctx.type = 'text/html';
42
- ctx.body = content;
43
- }
44
- }
45
- }));
46
- server = app.listen();
47
- request = supertest(server);
48
- });
49
-
50
- afterAll(() => { server.close(); });
51
-
52
- test('/about serves about.ejs', async () => {
53
- const response = await request.get('/about');
54
- expect(response.status).toBe(200);
55
- expect(response.text).toContain('About Page');
56
- });
57
-
58
- test('/blog/articolo serves blog/articolo.ejs (multi-level path)', async () => {
59
- const response = await request.get('/blog/articolo');
60
- expect(response.status).toBe(200);
61
- expect(response.text).toContain('Blog Article');
62
- });
63
-
64
- test('/style.css serves style.css (no interference with other extensions)', async () => {
65
- const response = await request.get('/style.css');
66
- expect(response.status).toBe(200);
67
- expect(response.text).toContain('body { color: red; }');
68
- });
69
-
70
- test('/ serves the index file via existing index flow', async () => {
71
- const response = await request.get('/');
72
- expect(response.status).toBe(200);
73
- expect(response.text).toContain('Home Page');
74
- });
75
- });
76
-
77
- // ==========================================
78
- // Redirect URL with extension → clean URL
79
- // ==========================================
80
- describe('Redirect URL with extension to clean URL', () => {
81
- let app, server, request;
82
-
83
- beforeAll(() => {
84
- app = new Koa();
85
- app.use(koaClassicServer(rootDir, {
86
- dirListing: { enabled: true },
87
- index: ['index.ejs'],
88
- hideExtension: { ext: '.ejs' },
89
- template: {
90
- ext: ['ejs'],
91
- render: async (ctx, next, filePath) => {
92
- const content = await fs.promises.readFile(filePath, 'utf-8');
93
- ctx.type = 'text/html';
94
- ctx.body = content;
95
- }
96
- }
97
- }));
98
- server = app.listen();
99
- request = supertest(server);
100
- });
101
-
102
- afterAll(() => { server.close(); });
103
-
104
- test('/about.ejs → redirect 301 to /about', async () => {
105
- const response = await request.get('/about.ejs');
106
- expect(response.status).toBe(301);
107
- expect(response.headers.location).toBe('/about');
108
- });
109
-
110
- test('/blog/articolo.ejs → redirect 301 to /blog/articolo', async () => {
111
- const response = await request.get('/blog/articolo.ejs');
112
- expect(response.status).toBe(301);
113
- expect(response.headers.location).toBe('/blog/articolo');
114
- });
115
-
116
- test('/about.ejs?lang=it → redirect 301 to /about?lang=it (preserves query string)', async () => {
117
- const response = await request.get('/about.ejs?lang=it');
118
- expect(response.status).toBe(301);
119
- expect(response.headers.location).toBe('/about?lang=it');
120
- });
121
-
122
- test('/index.ejs → redirect to /', async () => {
123
- const response = await request.get('/index.ejs');
124
- expect(response.status).toBe(301);
125
- expect(response.headers.location).toBe('/');
126
- });
127
-
128
- test('/sezione/index.ejs → redirect to /sezione/', async () => {
129
- const response = await request.get('/sezione/index.ejs');
130
- expect(response.status).toBe(301);
131
- expect(response.headers.location).toBe('/sezione/');
132
- });
133
- });
134
-
135
- // ==========================================
136
- // Custom redirect code (302)
137
- // ==========================================
138
- describe('Custom redirect code', () => {
139
- let app, server, request;
140
-
141
- beforeAll(() => {
142
- app = new Koa();
143
- app.use(koaClassicServer(rootDir, {
144
- dirListing: { enabled: true },
145
- index: ['index.ejs'],
146
- hideExtension: { ext: '.ejs', redirect: 302 }
147
- }));
148
- server = app.listen();
149
- request = supertest(server);
150
- });
151
-
152
- afterAll(() => { server.close(); });
153
-
154
- test('/about.ejs → redirect 302 to /about', async () => {
155
- const response = await request.get('/about.ejs');
156
- expect(response.status).toBe(302);
157
- expect(response.headers.location).toBe('/about');
158
- });
159
- });
160
-
161
- // ==========================================
162
- // Directory/file conflict (dirListing: { enabled: true })
163
- // ==========================================
164
- describe('Directory/file conflict with dirListing: { enabled: true }', () => {
165
- let app, server, request;
166
-
167
- beforeAll(() => {
168
- app = new Koa();
169
- app.use(koaClassicServer(rootDir, {
170
- dirListing: { enabled: true },
171
- index: ['index.html'],
172
- hideExtension: { ext: '.ejs' },
173
- template: {
174
- ext: ['ejs'],
175
- render: async (ctx, next, filePath) => {
176
- const content = await fs.promises.readFile(filePath, 'utf-8');
177
- ctx.type = 'text/html';
178
- ctx.body = content;
179
- }
180
- }
181
- }));
182
- server = app.listen();
183
- request = supertest(server);
184
- });
185
-
186
- afterAll(() => { server.close(); });
187
-
188
- test('/about serves about.ejs (file wins over directory)', async () => {
189
- const response = await request.get('/about');
190
- expect(response.status).toBe(200);
191
- expect(response.text).toContain('About Page');
192
- });
193
-
194
- test('/about/ shows the directory index or directory contents', async () => {
195
- const response = await request.get('/about/');
196
- expect(response.status).toBe(200);
197
- // The about/ directory has index.html, so it's served as the index file
198
- expect(response.text).toContain('About Directory Index');
199
- });
200
- });
201
-
202
- // ==========================================
203
- // Trailing slash without directory → 404
204
- // ==========================================
205
- describe('Trailing slash without directory', () => {
206
- let app, server, request;
207
-
208
- beforeAll(() => {
209
- app = new Koa();
210
- app.use(koaClassicServer(rootDir, {
211
- dirListing: { enabled: true },
212
- hideExtension: { ext: '.ejs' }
213
- }));
214
- server = app.listen();
215
- request = supertest(server);
216
- });
217
-
218
- afterAll(() => { server.close(); });
219
-
220
- test('/nonexistent/ returns 404', async () => {
221
- const response = await request.get('/nonexistent/');
222
- expect(response.status).toBe(404);
223
- });
224
- });
225
-
226
- // ==========================================
227
- // File without extension vs .ejs conflict
228
- // ==========================================
229
- describe('File without extension vs .ejs conflict', () => {
230
- let app, server, request;
231
-
232
- beforeAll(() => {
233
- app = new Koa();
234
- app.use(koaClassicServer(rootDir, {
235
- dirListing: { enabled: true },
236
- hideExtension: { ext: '.ejs' },
237
- template: {
238
- ext: ['ejs'],
239
- render: async (ctx, next, filePath) => {
240
- const content = await fs.promises.readFile(filePath, 'utf-8');
241
- ctx.type = 'text/html';
242
- ctx.body = content;
243
- }
244
- }
245
- }));
246
- server = app.listen();
247
- request = supertest(server);
248
- });
249
-
250
- afterAll(() => { server.close(); });
251
-
252
- test('/conflict-test/pagina serves pagina.ejs (ejs wins over extensionless file)', async () => {
253
- const response = await request.get('/conflict-test/pagina');
254
- expect(response.status).toBe(200);
255
- expect(response.text).toContain('Pagina EJS');
256
- });
257
- });
258
-
259
- // ==========================================
260
- // Interaction with urlsReserved
261
- // ==========================================
262
- describe('Interaction with urlsReserved', () => {
263
- let app, server, request;
264
-
265
- beforeAll(() => {
266
- app = new Koa();
267
-
268
- // Add a "next" middleware to catch reserved URLs
269
- app.use(async (ctx, next) => {
270
- await next();
271
- if (ctx.status === 404 && ctx._passedToNext) {
272
- ctx.status = 200;
273
- ctx.body = 'RESERVED';
274
- }
275
- });
276
-
277
- const middleware = koaClassicServer(rootDir, {
278
- dirListing: { enabled: true },
279
- index: ['index.ejs'],
280
- urlsReserved: ['/blog'],
281
- hideExtension: { ext: '.ejs' },
282
- template: {
283
- ext: ['ejs'],
284
- render: async (ctx, next, filePath) => {
285
- const content = await fs.promises.readFile(filePath, 'utf-8');
286
- ctx.type = 'text/html';
287
- ctx.body = content;
288
- }
289
- }
290
- });
291
-
292
- // Wrap middleware to track next() calls
293
- app.use(async (ctx, next) => {
294
- const originalNext = next;
295
- await middleware(ctx, async () => {
296
- ctx._passedToNext = true;
297
- await originalNext();
298
- });
299
- });
300
-
301
- server = app.listen();
302
- request = supertest(server);
303
- });
304
-
305
- afterAll(() => { server.close(); });
306
-
307
- test('/blog is reserved (passed to next middleware)', async () => {
308
- const response = await request.get('/blog');
309
- // blog is a directory and is reserved, so it passes to next
310
- expect(response.text).toBe('RESERVED');
311
- });
312
-
313
- test('/about still resolves about.ejs normally', async () => {
314
- const response = await request.get('/about');
315
- expect(response.status).toBe(200);
316
- expect(response.text).toContain('About Page');
317
- });
318
- });
319
-
320
- // ==========================================
321
- // Interaction with useOriginalUrl
322
- // ==========================================
323
- describe('Interaction with useOriginalUrl', () => {
324
- let app, server, request;
325
-
326
- beforeAll(() => {
327
- app = new Koa();
328
-
329
- // i18n middleware that rewrites URLs
330
- app.use(async (ctx, next) => {
331
- if (ctx.path.match(/^\/it\//)) {
332
- ctx.url = ctx.path.replace(/^\/it/, '');
333
- }
334
- await next();
335
- });
336
-
337
- app.use(koaClassicServer(rootDir, {
338
- dirListing: { enabled: true },
339
- useOriginalUrl: false,
340
- hideExtension: { ext: '.ejs' },
341
- template: {
342
- ext: ['ejs'],
343
- render: async (ctx, next, filePath) => {
344
- const content = await fs.promises.readFile(filePath, 'utf-8');
345
- ctx.type = 'text/html';
346
- ctx.body = content;
347
- }
348
- }
349
- }));
350
-
351
- server = app.listen();
352
- request = supertest(server);
353
- });
354
-
355
- afterAll(() => { server.close(); });
356
-
357
- test('redirect uses ctx.originalUrl (preserves /it/ prefix)', async () => {
358
- const response = await request.get('/it/about.ejs');
359
- expect(response.status).toBe(301);
360
- // Redirect should use originalUrl: /it/about (not /about)
361
- expect(response.headers.location).toBe('/it/about');
362
- });
363
-
364
- test('clean URL resolves through rewritten URL', async () => {
365
- const response = await request.get('/it/about');
366
- expect(response.status).toBe(200);
367
- expect(response.text).toContain('About Page');
368
- });
369
- });
370
-
371
- // ==========================================
372
- // Case-sensitive extension matching
373
- // ==========================================
374
- describe('Case-sensitive extension matching', () => {
375
- let app, server, request;
376
-
377
- beforeAll(() => {
378
- app = new Koa();
379
- app.use(koaClassicServer(rootDir, {
380
- dirListing: { enabled: true },
381
- hideExtension: { ext: '.ejs' }
382
- }));
383
- server = app.listen();
384
- request = supertest(server);
385
- });
386
-
387
- afterAll(() => { server.close(); });
388
-
389
- test('/about.EJS is not handled by hideExtension (case-sensitive)', async () => {
390
- const response = await request.get('/about.EJS');
391
- // The file about.EJS exists, should be served normally (not redirected)
392
- expect(response.status).toBe(200);
393
- // Should NOT be a redirect
394
- expect(response.status).not.toBe(301);
395
- });
396
- });
397
-
398
- // ==========================================
399
- // URLs with different extensions (no interference)
400
- // ==========================================
401
- describe('URLs with different extensions', () => {
402
- let app, server, request;
403
-
404
- beforeAll(() => {
405
- app = new Koa();
406
- app.use(koaClassicServer(rootDir, {
407
- dirListing: { enabled: true },
408
- hideExtension: { ext: '.ejs' }
409
- }));
410
- server = app.listen();
411
- request = supertest(server);
412
- });
413
-
414
- afterAll(() => { server.close(); });
415
-
416
- test('/file.ejs.bak is not interfered with', async () => {
417
- const response = await request.get('/file.ejs.bak');
418
- // .bak is the extension, not .ejs - should be served normally
419
- expect(response.status).toBe(200);
420
- expect(response.status).not.toBe(301);
421
- });
422
-
423
- test('/photo.txt is not interfered with', async () => {
424
- const response = await request.get('/photo.txt');
425
- expect(response.status).toBe(200);
426
- expect(response.status).not.toBe(301);
427
- });
428
-
429
- test('/style.css is not interfered with', async () => {
430
- const response = await request.get('/style.css');
431
- expect(response.status).toBe(200);
432
- expect(response.status).not.toBe(301);
433
- });
434
- });
435
-
436
- // ==========================================
437
- // Template engine integration
438
- // ==========================================
439
- describe('Template engine integration', () => {
440
- let app, server, request;
441
-
442
- beforeAll(() => {
443
- app = new Koa();
444
- app.use(koaClassicServer(rootDir, {
445
- dirListing: { enabled: true },
446
- index: ['index.ejs'],
447
- hideExtension: { ext: '.ejs' },
448
- template: {
449
- ext: ['ejs'],
450
- render: async (ctx, next, filePath) => {
451
- const templateContent = await fs.promises.readFile(filePath, 'utf-8');
452
- const html = ejs.render(templateContent, { title: 'Test Title' });
453
- ctx.type = 'text/html';
454
- ctx.body = html;
455
- }
456
- }
457
- }));
458
- server = app.listen();
459
- request = supertest(server);
460
- });
461
-
462
- afterAll(() => { server.close(); });
463
-
464
- test('file resolved via hideExtension passes correctly to template engine', async () => {
465
- const response = await request.get('/about');
466
- expect(response.status).toBe(200);
467
- expect(response.type).toBe('text/html');
468
- expect(response.text).toContain('About Page');
469
- });
470
- });
471
-
472
- // ==========================================
473
- // Input Validation
474
- // ==========================================
475
- describe('Input validation', () => {
476
-
477
- test('hideExtension: true → throws Error', () => {
478
- expect(() => {
479
- koaClassicServer(rootDir, {
480
- hideExtension: true
481
- });
482
- }).toThrow();
483
- });
484
-
485
- test('hideExtension: {} → throws Error (missing ext)', () => {
486
- expect(() => {
487
- koaClassicServer(rootDir, {
488
- hideExtension: {}
489
- });
490
- }).toThrow();
491
- });
492
-
493
- test('hideExtension: { ext: "" } → throws Error (empty ext)', () => {
494
- expect(() => {
495
- koaClassicServer(rootDir, {
496
- hideExtension: { ext: '' }
497
- });
498
- }).toThrow();
499
- });
500
-
501
- test('hideExtension: { ext: "ejs" } → warning + normalizes to ".ejs"', () => {
502
- const originalWarn = console.warn;
503
- const warnings = [];
504
- console.warn = (...args) => { warnings.push(args); };
505
-
506
- try {
507
- const middleware = koaClassicServer(rootDir, {
508
- hideExtension: { ext: 'ejs' }
509
- });
510
- // Should not throw
511
- expect(middleware).toBeDefined();
512
- // Should have issued a warning
513
- expect(warnings.length).toBeGreaterThan(0);
514
- expect(warnings[0][1]).toContain('hideExtension.ext should start with a dot');
515
- } finally {
516
- console.warn = originalWarn;
517
- }
518
- });
519
-
520
- test('hideExtension: { ext: ".ejs", redirect: "abc" } → throws Error (redirect not numeric)', () => {
521
- expect(() => {
522
- koaClassicServer(rootDir, {
523
- hideExtension: { ext: '.ejs', redirect: 'abc' }
524
- });
525
- }).toThrow();
526
- });
527
-
528
- test('hideExtension: { ext: ".ejs" } → valid, default redirect 301', () => {
529
- expect(() => {
530
- koaClassicServer(rootDir, {
531
- hideExtension: { ext: '.ejs' }
532
- });
533
- }).not.toThrow();
534
- });
535
-
536
- test('hideExtension: { ext: ".ejs", redirect: 302 } → valid', () => {
537
- expect(() => {
538
- koaClassicServer(rootDir, {
539
- hideExtension: { ext: '.ejs', redirect: 302 }
540
- });
541
- }).not.toThrow();
542
- });
543
-
544
- test('hideExtension: undefined → feature disabled (no error)', () => {
545
- expect(() => {
546
- koaClassicServer(rootDir, {});
547
- }).not.toThrow();
548
- });
549
- });
550
-
551
- // ==========================================
552
- // Security: open-redirect via protocol-relative URL
553
- // ==========================================
554
- describe('Open-redirect guard on hideExtension Location header', () => {
555
- let app, server, port;
556
-
557
- beforeAll((done) => {
558
- app = new Koa();
559
- app.use(koaClassicServer(rootDir, {
560
- dirListing: { enabled: true },
561
- hideExtension: { ext: '.ejs' }
562
- }));
563
- server = app.listen(0, () => {
564
- port = server.address().port;
565
- done();
566
- });
567
- });
568
-
569
- afterAll(() => { server.close(); });
570
-
571
- // Send the request path as raw bytes so the leading "//" survives any
572
- // client-side URL normalization. supertest/url.parse would collapse it.
573
- function rawGet(path) {
574
- const http = require('http');
575
- return new Promise((resolve, reject) => {
576
- const req = http.request({
577
- host: '127.0.0.1',
578
- port,
579
- method: 'GET',
580
- path
581
- }, (res) => {
582
- res.resume();
583
- resolve({ status: res.statusCode, location: res.headers.location });
584
- });
585
- req.on('error', reject);
586
- req.end();
587
- });
588
- }
589
-
590
- test('GET //evil.com/foo.ejs → Location must not be protocol-relative', async () => {
591
- const res = await rawGet('//evil.com/foo.ejs');
592
- expect(res.status).toBe(301);
593
- expect(res.location).toBeDefined();
594
- // The Location must not start with "//" or "/\" (would navigate off-origin)
595
- expect(res.location.startsWith('//')).toBe(false);
596
- expect(res.location.startsWith('/\\')).toBe(false);
597
- expect(res.location).toBe('/evil.com/foo');
598
- });
599
-
600
- test('GET ///a/b.ejs → leading slashes collapsed in Location', async () => {
601
- const res = await rawGet('///a/b.ejs');
602
- expect(res.status).toBe(301);
603
- expect(res.location.startsWith('//')).toBe(false);
604
- expect(res.location).toBe('/a/b');
605
- });
606
- });
607
- });