koa-classic-server 2.1.4 → 2.4.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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Symlink support tests for koa-classic-server
3
+ *
4
+ * Context: On NixOS with buildFHSEnv (chroot-like environment used for Playwright tests),
5
+ * files in the www/ directory appear as symlinks to the Nix store instead of regular files.
6
+ * This caused two failures:
7
+ * - GET / returned a directory listing ("Index of /") instead of rendering index.ejs
8
+ * - GET /index.ejs returned 404 instead of 200
9
+ *
10
+ * Root cause: fs.readdir({ withFileTypes: true }) classifies symlinks as
11
+ * isSymbolicLink()=true, isFile()=false. The findIndexFile() function filtered
12
+ * with dirent.isFile(), excluding all symlinks from index file discovery.
13
+ *
14
+ * The fix introduces isFileOrSymlinkToFile() / isDirOrSymlinkToDir() helpers
15
+ * that follow symlinks via fs.promises.stat() only when dirent.isSymbolicLink()
16
+ * is true, adding zero overhead for regular files.
17
+ *
18
+ * These tests also cover: Docker bind mounts, npm link, Capistrano-style deploys,
19
+ * and any other scenario where files are served through symbolic links.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const Koa = require('koa');
26
+ const supertest = require('supertest');
27
+ const koaClassicServer = require('../index.cjs');
28
+
29
+ // Detect if the OS supports symlinks (Windows without dev mode may not)
30
+ let symlinkSupported = true;
31
+ try {
32
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'symlink-check-'));
33
+ const testFile = path.join(testDir, 'target');
34
+ const testLink = path.join(testDir, 'link');
35
+ fs.writeFileSync(testFile, 'test');
36
+ fs.symlinkSync(testFile, testLink);
37
+ fs.rmSync(testDir, { recursive: true, force: true });
38
+ } catch {
39
+ symlinkSupported = false;
40
+ }
41
+
42
+ const describeIfSymlinks = symlinkSupported ? describe : describe.skip;
43
+
44
+ describeIfSymlinks('koa-classic-server - symlink support', () => {
45
+ let tmpDir;
46
+
47
+ beforeAll(() => {
48
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-symlink-test-'));
49
+
50
+ // --- Regular files ---
51
+ fs.writeFileSync(
52
+ path.join(tmpDir, 'index.html'),
53
+ '<html><head><title>Regular Index</title></head><body>Hello</body></html>'
54
+ );
55
+ fs.writeFileSync(
56
+ path.join(tmpDir, 'style.css'),
57
+ 'body { color: red; }'
58
+ );
59
+
60
+ // --- Real file that will be the symlink target for index.ejs ---
61
+ fs.writeFileSync(
62
+ path.join(tmpDir, 'real-index.ejs'),
63
+ '<html><head><title>EJS via Symlink</title></head><body><h1>Works</h1></body></html>'
64
+ );
65
+
66
+ // --- Symlink to file (the core bug scenario) ---
67
+ fs.symlinkSync(
68
+ path.join(tmpDir, 'real-index.ejs'),
69
+ path.join(tmpDir, 'index.ejs')
70
+ );
71
+
72
+ // --- Symlink to regular file (non-index) ---
73
+ fs.symlinkSync(
74
+ path.join(tmpDir, 'style.css'),
75
+ path.join(tmpDir, 'linked-style.css')
76
+ );
77
+
78
+ // --- Real subdirectory with a file ---
79
+ const realSubdir = path.join(tmpDir, 'real-subdir');
80
+ fs.mkdirSync(realSubdir);
81
+ fs.writeFileSync(
82
+ path.join(realSubdir, 'file.txt'),
83
+ 'content inside real-subdir'
84
+ );
85
+
86
+ // --- Symlink to directory ---
87
+ fs.symlinkSync(
88
+ realSubdir,
89
+ path.join(tmpDir, 'linked-subdir')
90
+ );
91
+
92
+ // --- Broken symlink (target does not exist) ---
93
+ fs.symlinkSync(
94
+ path.join(tmpDir, 'non-existent-file.html'),
95
+ path.join(tmpDir, 'broken-link.html')
96
+ );
97
+
98
+ // --- Circular symlinks ---
99
+ try {
100
+ fs.symlinkSync(
101
+ path.join(tmpDir, 'circular-b'),
102
+ path.join(tmpDir, 'circular-a')
103
+ );
104
+ fs.symlinkSync(
105
+ path.join(tmpDir, 'circular-a'),
106
+ path.join(tmpDir, 'circular-b')
107
+ );
108
+ } catch {
109
+ // Some systems may not support circular symlinks creation
110
+ }
111
+ });
112
+
113
+ afterAll(() => {
114
+ fs.rmSync(tmpDir, { recursive: true, force: true });
115
+ });
116
+
117
+ // =========================================================================
118
+ // 1. REGRESSION - Regular file as index
119
+ // =========================================================================
120
+ describe('regular file as index (regression)', () => {
121
+ let server, request;
122
+
123
+ beforeAll(() => {
124
+ const app = new Koa();
125
+ app.use(koaClassicServer(tmpDir, {
126
+ index: ['index.html'],
127
+ showDirContents: true
128
+ }));
129
+ server = app.listen();
130
+ request = supertest(server);
131
+ });
132
+
133
+ afterAll(() => { server?.close(); });
134
+
135
+ test('GET / serves regular index.html, not directory listing', async () => {
136
+ const res = await request.get('/');
137
+ expect(res.status).toBe(200);
138
+ expect(res.text).toContain('Regular Index');
139
+ expect(res.text).not.toContain('Index of');
140
+ });
141
+ });
142
+
143
+ // =========================================================================
144
+ // 2. BUG FIX - Symlink to file as index
145
+ // =========================================================================
146
+ describe('symlink to file as index (bug fix)', () => {
147
+ let server, request;
148
+
149
+ beforeAll(() => {
150
+ const app = new Koa();
151
+ app.use(koaClassicServer(tmpDir, {
152
+ index: ['index.ejs'],
153
+ showDirContents: true
154
+ }));
155
+ server = app.listen();
156
+ request = supertest(server);
157
+ });
158
+
159
+ afterAll(() => { server?.close(); });
160
+
161
+ test('GET / serves symlinked index.ejs, not directory listing', async () => {
162
+ const res = await request.get('/');
163
+ expect(res.status).toBe(200);
164
+ expect(res.text).toContain('EJS via Symlink');
165
+ expect(res.text).not.toContain('Index of');
166
+ });
167
+ });
168
+
169
+ // =========================================================================
170
+ // 3. BUG FIX - Direct GET to symlinked file returns 200
171
+ // =========================================================================
172
+ describe('direct GET to symlinked file (bug fix)', () => {
173
+ let server, request;
174
+
175
+ beforeAll(() => {
176
+ const app = new Koa();
177
+ app.use(koaClassicServer(tmpDir, {
178
+ index: [],
179
+ showDirContents: true
180
+ }));
181
+ server = app.listen();
182
+ request = supertest(server);
183
+ });
184
+
185
+ afterAll(() => { server?.close(); });
186
+
187
+ test('GET /index.ejs via symlink returns 200', async () => {
188
+ const res = await request.get('/index.ejs');
189
+ expect(res.status).toBe(200);
190
+ expect(res.text).toContain('EJS via Symlink');
191
+ });
192
+
193
+ test('GET /linked-style.css via symlink returns 200 with correct mime', async () => {
194
+ const res = await request.get('/linked-style.css');
195
+ expect(res.status).toBe(200);
196
+ expect(res.headers['content-type']).toMatch(/css/);
197
+ expect(res.text).toContain('body { color: red; }');
198
+ });
199
+ });
200
+
201
+ // =========================================================================
202
+ // 4. BUG FIX - Symlink to file with template engine
203
+ // =========================================================================
204
+ describe('EJS template via symlink (bug fix)', () => {
205
+ let server, request;
206
+
207
+ beforeAll(() => {
208
+ const app = new Koa();
209
+ app.use(koaClassicServer(tmpDir, {
210
+ index: ['index.ejs'],
211
+ showDirContents: true,
212
+ template: {
213
+ ext: ['ejs'],
214
+ render: async (ctx, next, filePath) => {
215
+ const content = await fs.promises.readFile(filePath, 'utf-8');
216
+ ctx.type = 'text/html';
217
+ ctx.body = content;
218
+ }
219
+ }
220
+ }));
221
+ server = app.listen();
222
+ request = supertest(server);
223
+ });
224
+
225
+ afterAll(() => { server?.close(); });
226
+
227
+ test('GET / renders EJS template through symlink', async () => {
228
+ const res = await request.get('/');
229
+ expect(res.status).toBe(200);
230
+ expect(res.headers['content-type']).toMatch(/html/);
231
+ expect(res.text).toContain('EJS via Symlink');
232
+ });
233
+ });
234
+
235
+ // =========================================================================
236
+ // 5. Directory as symlink
237
+ // =========================================================================
238
+ describe('symlink to directory', () => {
239
+ let server, request;
240
+
241
+ beforeAll(() => {
242
+ const app = new Koa();
243
+ app.use(koaClassicServer(tmpDir, {
244
+ index: [],
245
+ showDirContents: true
246
+ }));
247
+ server = app.listen();
248
+ request = supertest(server);
249
+ });
250
+
251
+ afterAll(() => { server?.close(); });
252
+
253
+ test('GET /linked-subdir/ lists directory contents', async () => {
254
+ const res = await request.get('/linked-subdir/');
255
+ expect(res.status).toBe(200);
256
+ expect(res.text).toContain('file.txt');
257
+ });
258
+
259
+ test('GET /linked-subdir/file.txt serves file inside symlinked dir', async () => {
260
+ const res = await request.get('/linked-subdir/file.txt');
261
+ expect(res.status).toBe(200);
262
+ expect(res.text).toContain('content inside real-subdir');
263
+ });
264
+ });
265
+
266
+ // =========================================================================
267
+ // 6. Broken symlink
268
+ // =========================================================================
269
+ describe('broken symlink', () => {
270
+ let server, request;
271
+
272
+ beforeAll(() => {
273
+ const app = new Koa();
274
+ app.use(koaClassicServer(tmpDir, {
275
+ index: [],
276
+ showDirContents: true
277
+ }));
278
+ server = app.listen();
279
+ request = supertest(server);
280
+ });
281
+
282
+ afterAll(() => { server?.close(); });
283
+
284
+ test('GET /broken-link.html returns 404, not crash', async () => {
285
+ const res = await request.get('/broken-link.html');
286
+ expect(res.status).toBe(404);
287
+ });
288
+ });
289
+
290
+ // =========================================================================
291
+ // 7. Circular symlink
292
+ // =========================================================================
293
+ describe('circular symlink', () => {
294
+ let server, request;
295
+ let circularExists = false;
296
+
297
+ beforeAll(() => {
298
+ circularExists = fs.existsSync(path.join(tmpDir, 'circular-a'));
299
+ const app = new Koa();
300
+ app.use(koaClassicServer(tmpDir, {
301
+ index: [],
302
+ showDirContents: true
303
+ }));
304
+ server = app.listen();
305
+ request = supertest(server);
306
+ });
307
+
308
+ afterAll(() => { server?.close(); });
309
+
310
+ test('GET /circular-a does not cause infinite loop', async () => {
311
+ if (!circularExists) {
312
+ // Skip if circular symlinks could not be created on this OS
313
+ return;
314
+ }
315
+ const res = await request.get('/circular-a');
316
+ // Should return an error status, not hang
317
+ expect([404, 500]).toContain(res.status);
318
+ });
319
+ });
320
+
321
+ // =========================================================================
322
+ // 8. REGRESSION - Regular non-index file unchanged
323
+ // =========================================================================
324
+ describe('regular non-index file (regression)', () => {
325
+ let server, request;
326
+
327
+ beforeAll(() => {
328
+ const app = new Koa();
329
+ app.use(koaClassicServer(tmpDir, {
330
+ index: ['index.html'],
331
+ showDirContents: true
332
+ }));
333
+ server = app.listen();
334
+ request = supertest(server);
335
+ });
336
+
337
+ afterAll(() => { server?.close(); });
338
+
339
+ test('GET /style.css serves regular file correctly', async () => {
340
+ const res = await request.get('/style.css');
341
+ expect(res.status).toBe(200);
342
+ expect(res.headers['content-type']).toMatch(/css/);
343
+ expect(res.text).toContain('body { color: red; }');
344
+ });
345
+ });
346
+
347
+ // =========================================================================
348
+ // 9. Directory listing shows symlink indicators
349
+ // =========================================================================
350
+ describe('directory listing symlink indicators', () => {
351
+ let server, request;
352
+
353
+ beforeAll(() => {
354
+ const app = new Koa();
355
+ app.use(koaClassicServer(tmpDir, {
356
+ index: [],
357
+ showDirContents: true
358
+ }));
359
+ server = app.listen();
360
+ request = supertest(server);
361
+ });
362
+
363
+ afterAll(() => { server?.close(); });
364
+
365
+ test('symlink to file shows ( Symlink ) indicator', async () => {
366
+ const res = await request.get('/');
367
+ expect(res.status).toBe(200);
368
+ // index.ejs is a symlink to a file
369
+ expect(res.text).toContain('index.ejs');
370
+ expect(res.text).toMatch(/index\.ejs<\/a>\s*\( Symlink \)/);
371
+ });
372
+
373
+ test('symlink to directory shows ( Symlink ) indicator', async () => {
374
+ const res = await request.get('/');
375
+ expect(res.status).toBe(200);
376
+ expect(res.text).toContain('linked-subdir');
377
+ expect(res.text).toMatch(/linked-subdir<\/a>\s*\( Symlink \)/);
378
+ });
379
+
380
+ test('broken symlink shows ( Broken Symlink ) indicator without link', async () => {
381
+ const res = await request.get('/');
382
+ expect(res.status).toBe(200);
383
+ expect(res.text).toContain('broken-link.html');
384
+ expect(res.text).toContain('( Broken Symlink )');
385
+ // Broken symlink name should NOT be wrapped in <a> tag
386
+ expect(res.text).not.toMatch(/<a[^>]*>broken-link\.html<\/a>/);
387
+ });
388
+
389
+ test('regular file does NOT show symlink indicator', async () => {
390
+ const res = await request.get('/');
391
+ expect(res.status).toBe(200);
392
+ expect(res.text).toContain('style.css');
393
+ // style.css (not linked-style.css) should not have any symlink indicator
394
+ expect(res.text).not.toMatch(/>style\.css<\/a>\s*\( Symlink \)/);
395
+ expect(res.text).not.toMatch(/>style\.css<\/a>\s*\( Broken Symlink \)/);
396
+ });
397
+
398
+ test('symlink to file shows target mime type, not "unknown"', async () => {
399
+ const res = await request.get('/');
400
+ expect(res.status).toBe(200);
401
+ // linked-style.css is a symlink to style.css - should show text/css mime
402
+ expect(res.text).toMatch(/linked-style\.css[\s\S]*?text\/css/);
403
+ });
404
+
405
+ test('symlink to directory shows DIR type', async () => {
406
+ const res = await request.get('/');
407
+ expect(res.status).toBe(200);
408
+ // linked-subdir is a symlink to a directory - should show DIR
409
+ expect(res.text).toMatch(/linked-subdir[\s\S]*?DIR/);
410
+ });
411
+ });
412
+
413
+ // =========================================================================
414
+ // 10. Symlink as index with RegExp pattern
415
+ // =========================================================================
416
+ describe('symlink as index with RegExp pattern', () => {
417
+ let server, request;
418
+
419
+ beforeAll(() => {
420
+ const app = new Koa();
421
+ app.use(koaClassicServer(tmpDir, {
422
+ index: [/index\.[eE][jJ][sS]/],
423
+ showDirContents: true
424
+ }));
425
+ server = app.listen();
426
+ request = supertest(server);
427
+ });
428
+
429
+ afterAll(() => { server?.close(); });
430
+
431
+ test('GET / finds symlinked index.ejs via RegExp pattern', async () => {
432
+ const res = await request.get('/');
433
+ expect(res.status).toBe(200);
434
+ expect(res.text).toContain('EJS via Symlink');
435
+ expect(res.text).not.toContain('Index of');
436
+ });
437
+ });
438
+ });
@@ -0,0 +1,213 @@
1
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
2
+ //
3
+ // TEST FOR useOriginalUrl OPTION
4
+ // This test verifies that the useOriginalUrl option works correctly with URL rewriting middleware
5
+ //
6
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
7
+
8
+ const supertest = require('supertest');
9
+ const koaClassicServer = require('../index.cjs');
10
+ const Koa = require('koa');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const rootDir = path.join(__dirname, 'publicWwwTest');
15
+
16
+ // Create a simple test file if it doesn't exist
17
+ const testFilePath = path.join(rootDir, 'test-page.html');
18
+ const testFileContent = '<!DOCTYPE html><html><head><title>Test Page</title></head><body><h1>Test Page</h1></body></html>';
19
+
20
+ beforeAll(() => {
21
+ // Ensure test directory exists
22
+ if (!fs.existsSync(rootDir)) {
23
+ fs.mkdirSync(rootDir, { recursive: true });
24
+ }
25
+
26
+ // Create test file
27
+ if (!fs.existsSync(testFilePath)) {
28
+ fs.writeFileSync(testFilePath, testFileContent, 'utf-8');
29
+ }
30
+ });
31
+
32
+ describe('useOriginalUrl option tests', () => {
33
+
34
+ describe('Default behavior (useOriginalUrl: true)', () => {
35
+ let app;
36
+ let server;
37
+
38
+ beforeAll(() => {
39
+ app = new Koa();
40
+
41
+ // i18n middleware that rewrites URLs
42
+ app.use(async (ctx, next) => {
43
+ if (ctx.path.match(/^\/it\//)) {
44
+ // Rewrite /it/page.html to /page.html
45
+ ctx.url = ctx.path.replace(/^\/it/, '');
46
+ }
47
+ await next();
48
+ });
49
+
50
+ // Serve files with default useOriginalUrl: true
51
+ app.use(koaClassicServer(rootDir, {
52
+ useOriginalUrl: true // Default behavior
53
+ }));
54
+
55
+ server = app.listen();
56
+ });
57
+
58
+ afterAll(() => {
59
+ server.close();
60
+ });
61
+
62
+ test('should use ctx.originalUrl (original request path)', async () => {
63
+ // Request /it/test-page.html
64
+ // ctx.originalUrl = /it/test-page.html (unchanged)
65
+ // ctx.url = /test-page.html (rewritten by middleware)
66
+ // With useOriginalUrl: true, server looks for /it/test-page.html (which doesn't exist)
67
+ const response = await supertest(server).get('/it/test-page.html');
68
+
69
+ // Should return 404 because /it/test-page.html doesn't exist
70
+ expect(response.status).toBe(404);
71
+ });
72
+
73
+ test('should serve file without rewriting', async () => {
74
+ // Request /test-page.html directly (no rewriting)
75
+ const response = await supertest(server).get('/test-page.html');
76
+
77
+ // Should return 200 and the file content
78
+ expect(response.status).toBe(200);
79
+ expect(response.text).toContain('Test Page');
80
+ });
81
+ });
82
+
83
+ describe('URL rewriting support (useOriginalUrl: false)', () => {
84
+ let app;
85
+ let server;
86
+
87
+ beforeAll(() => {
88
+ app = new Koa();
89
+
90
+ // i18n middleware that rewrites URLs
91
+ app.use(async (ctx, next) => {
92
+ if (ctx.path.match(/^\/it\//)) {
93
+ // Rewrite /it/page.html to /page.html
94
+ ctx.url = ctx.path.replace(/^\/it/, '');
95
+ }
96
+ await next();
97
+ });
98
+
99
+ // Serve files with useOriginalUrl: false to use rewritten URL
100
+ app.use(koaClassicServer(rootDir, {
101
+ useOriginalUrl: false // Use ctx.url (rewritten)
102
+ }));
103
+
104
+ server = app.listen();
105
+ });
106
+
107
+ afterAll(() => {
108
+ server.close();
109
+ });
110
+
111
+ test('should use ctx.url (rewritten path)', async () => {
112
+ // Request /it/test-page.html
113
+ // ctx.originalUrl = /it/test-page.html (unchanged)
114
+ // ctx.url = /test-page.html (rewritten by middleware)
115
+ // With useOriginalUrl: false, server looks for /test-page.html (which exists)
116
+ const response = await supertest(server).get('/it/test-page.html');
117
+
118
+ // Should return 200 and the file content
119
+ expect(response.status).toBe(200);
120
+ expect(response.text).toContain('Test Page');
121
+ });
122
+
123
+ test('should still serve file without rewriting', async () => {
124
+ // Request /test-page.html directly (no rewriting)
125
+ const response = await supertest(server).get('/test-page.html');
126
+
127
+ // Should return 200 and the file content
128
+ expect(response.status).toBe(200);
129
+ expect(response.text).toContain('Test Page');
130
+ });
131
+ });
132
+
133
+ describe('Complex i18n routing scenario', () => {
134
+ let app;
135
+ let server;
136
+
137
+ beforeAll(() => {
138
+ app = new Koa();
139
+
140
+ // More complex i18n middleware
141
+ app.use(async (ctx, next) => {
142
+ const langPattern = /^\/(it|fr|de|es)\//;
143
+ const match = ctx.path.match(langPattern);
144
+ if (match) {
145
+ // Store language in state
146
+ ctx.state.lang = match[1];
147
+ // Strip language prefix
148
+ ctx.url = ctx.path.replace(langPattern, '/');
149
+ }
150
+ await next();
151
+ });
152
+
153
+ app.use(koaClassicServer(rootDir, {
154
+ useOriginalUrl: false
155
+ }));
156
+
157
+ server = app.listen();
158
+ });
159
+
160
+ afterAll(() => {
161
+ server.close();
162
+ });
163
+
164
+ test('should work with Italian locale (/it/)', async () => {
165
+ const response = await supertest(server).get('/it/test-page.html');
166
+ expect(response.status).toBe(200);
167
+ expect(response.text).toContain('Test Page');
168
+ });
169
+
170
+ test('should work with French locale (/fr/)', async () => {
171
+ const response = await supertest(server).get('/fr/test-page.html');
172
+ expect(response.status).toBe(200);
173
+ expect(response.text).toContain('Test Page');
174
+ });
175
+
176
+ test('should work with German locale (/de/)', async () => {
177
+ const response = await supertest(server).get('/de/test-page.html');
178
+ expect(response.status).toBe(200);
179
+ expect(response.text).toContain('Test Page');
180
+ });
181
+
182
+ test('should work with Spanish locale (/es/)', async () => {
183
+ const response = await supertest(server).get('/es/test-page.html');
184
+ expect(response.status).toBe(200);
185
+ expect(response.text).toContain('Test Page');
186
+ });
187
+ });
188
+
189
+ describe('Backward compatibility', () => {
190
+ let app;
191
+ let server;
192
+
193
+ beforeAll(() => {
194
+ app = new Koa();
195
+
196
+ // No URL rewriting middleware
197
+ // Default useOriginalUrl (should be true)
198
+ app.use(koaClassicServer(rootDir));
199
+
200
+ server = app.listen();
201
+ });
202
+
203
+ afterAll(() => {
204
+ server.close();
205
+ });
206
+
207
+ test('should work with default options (backward compatible)', async () => {
208
+ const response = await supertest(server).get('/test-page.html');
209
+ expect(response.status).toBe(200);
210
+ expect(response.text).toContain('Test Page');
211
+ });
212
+ });
213
+ });