@flancer32/teq-web 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +101 -0
- package/CHANGELOG.md +21 -3
- package/README.md +12 -6
- package/package.json +1 -1
- package/src/AGENTS.md +108 -0
- package/src/Back/Dto/Handler/Source.js +46 -0
- package/src/Back/Handler/Static/A/Config.js +58 -0
- package/src/Back/Handler/Static/A/Fallback.js +39 -0
- package/src/Back/Handler/Static/A/FileService.js +69 -0
- package/src/Back/Handler/Static/A/Registry.js +52 -0
- package/src/Back/Handler/Static/A/Resolver.js +83 -0
- package/src/Back/Handler/Static.js +25 -101
- package/src/Back/Helper/Cast.js +28 -0
- package/src/Back/Helper/Respond.js +77 -0
- package/teqfw.json +8 -0
- package/test/accept/Server.test.mjs +58 -5
- package/test/dev/app/Plugin/Start.js +5 -7
- package/test/unit/AGENTS.md +106 -0
- package/test/unit/Back/Dispatcher.test.mjs +150 -0
- package/test/unit/Back/Dto/Handler/Source.test.mjs +40 -0
- package/test/unit/Back/Handler/Pre/Log.test.mjs +22 -0
- package/test/unit/Back/Handler/Static/A/Config.test.mjs +52 -0
- package/test/unit/Back/Handler/Static/A/Fallback.test.mjs +60 -0
- package/test/unit/Back/Handler/Static/A/FileService.test.mjs +225 -0
- package/test/unit/Back/Handler/Static/A/Registry.test.mjs +83 -0
- package/test/unit/Back/Handler/Static/A/Resolver.test.mjs +73 -0
- package/test/unit/Back/Handler/Static/Static.test.mjs +235 -0
- package/test/unit/Back/Helper/Respond.test.mjs +83 -0
- package/src/Back/Handler/Npm.js +0 -161
- package/test/unit/Back/Handler/Npm.test.mjs +0 -69
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'assert';
|
|
3
|
+
import {buildTestContainer} from '../../../common.js';
|
|
4
|
+
|
|
5
|
+
test.describe('Fl32_Web_Back_Dto_Handler_Source', () => {
|
|
6
|
+
test('should create valid config DTO with casted fields', async () => {
|
|
7
|
+
const container = buildTestContainer();
|
|
8
|
+
container.register('Fl32_Web_Back_Helper_Cast$', {
|
|
9
|
+
string: (d) => typeof d === 'string' ? d : undefined,
|
|
10
|
+
stringArrayMap: (d) => d,
|
|
11
|
+
array: (d, item) => Array.isArray(d) ? d.map(item) : [],
|
|
12
|
+
});
|
|
13
|
+
const factory = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
14
|
+
const dto = factory.create({
|
|
15
|
+
root: '/abs/path',
|
|
16
|
+
prefix: '/src/',
|
|
17
|
+
allow: { vue: ['dist/vue.global.js'] },
|
|
18
|
+
defaults: ['index.html'],
|
|
19
|
+
});
|
|
20
|
+
assert.strictEqual(dto.root, '/abs/path');
|
|
21
|
+
assert.strictEqual(dto.prefix, '/src/');
|
|
22
|
+
assert.deepStrictEqual(dto.allow, { vue: ['dist/vue.global.js'] });
|
|
23
|
+
assert.deepStrictEqual(dto.defaults, ['index.html']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('should return undefined fields if values are invalid', async () => {
|
|
27
|
+
const container = buildTestContainer();
|
|
28
|
+
container.register('Fl32_Web_Back_Helper_Cast$', {
|
|
29
|
+
string: () => undefined,
|
|
30
|
+
stringArrayMap: () => ({}),
|
|
31
|
+
array: () => [],
|
|
32
|
+
});
|
|
33
|
+
const factory = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
34
|
+
const dto = factory.create({});
|
|
35
|
+
assert.strictEqual(dto.root, undefined);
|
|
36
|
+
assert.strictEqual(dto.prefix, undefined);
|
|
37
|
+
assert.deepStrictEqual(dto.allow, {});
|
|
38
|
+
assert.deepStrictEqual(dto.defaults, []);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {describe, it, beforeEach} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {buildTestContainer} from '../../../common.js';
|
|
4
|
+
|
|
5
|
+
describe('Fl32_Web_Back_Handler_Pre_Log', () => {
|
|
6
|
+
let container;
|
|
7
|
+
const log = [];
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
log.length = 0;
|
|
11
|
+
container = buildTestContainer();
|
|
12
|
+
container.register('Fl32_Web_Back_Logger$', {
|
|
13
|
+
debug: (msg) => log.push(msg),
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('logs method and url', async () => {
|
|
18
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Pre_Log$');
|
|
19
|
+
await handler.handle({method: 'GET', url: '/path'});
|
|
20
|
+
assert.deepStrictEqual(log, ['GET /path']);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {describe, it} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {buildTestContainer} from '../../../../common.js';
|
|
4
|
+
|
|
5
|
+
describe('Fl32_Web_Back_Handler_Static_A_Config', () => {
|
|
6
|
+
it('normalizes root, prefix, allow and defaults', async () => {
|
|
7
|
+
const container = buildTestContainer();
|
|
8
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Config} */
|
|
9
|
+
const factory = await container.get('Fl32_Web_Back_Handler_Static_A_Config$');
|
|
10
|
+
|
|
11
|
+
const dto = {
|
|
12
|
+
root: './r',
|
|
13
|
+
prefix: '/p',
|
|
14
|
+
allow: {'.': ['.']},
|
|
15
|
+
defaults: []
|
|
16
|
+
};
|
|
17
|
+
const cfg = factory.create(dto);
|
|
18
|
+
|
|
19
|
+
// root is resolved via node:path from the container
|
|
20
|
+
const {resolve} = await import('node:path');
|
|
21
|
+
assert.strictEqual(cfg.root, resolve('./r'));
|
|
22
|
+
|
|
23
|
+
// prefix is normalized to always end with a slash
|
|
24
|
+
assert.strictEqual(cfg.prefix, '/p/');
|
|
25
|
+
|
|
26
|
+
// allowed extensions are preserved
|
|
27
|
+
assert.deepStrictEqual(cfg.allow, {'.': ['.']});
|
|
28
|
+
|
|
29
|
+
// defaults fallback to built-in list when empty
|
|
30
|
+
assert.deepStrictEqual(
|
|
31
|
+
cfg.defaults,
|
|
32
|
+
['index.html', 'index.htm', 'index.txt']
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws on invalid data', async () => {
|
|
37
|
+
const container = buildTestContainer();
|
|
38
|
+
const factory = await container.get('Fl32_Web_Back_Handler_Static_A_Config$');
|
|
39
|
+
|
|
40
|
+
// missing root should throw an error about root
|
|
41
|
+
assert.throws(
|
|
42
|
+
() => factory.create({prefix: '/'}),
|
|
43
|
+
/Field 'root' must be a string/
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// non-string prefix should throw an error about prefix
|
|
47
|
+
assert.throws(
|
|
48
|
+
() => factory.create({root: 'a', prefix: 5}),
|
|
49
|
+
/Field 'prefix' must be a string/
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {describe, it, beforeEach} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {buildTestContainer} from '../../../../common.js';
|
|
4
|
+
|
|
5
|
+
describe('Fl32_Web_Back_Handler_Static_A_Fallback', () => {
|
|
6
|
+
/** @type {{ promises: { stat: (p: string) => Promise<any> }, _add: (p: string, isFile: boolean) => void }} */
|
|
7
|
+
let mockFs;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
const storage = new Map();
|
|
11
|
+
|
|
12
|
+
mockFs = {
|
|
13
|
+
promises: {
|
|
14
|
+
stat: async p => {
|
|
15
|
+
// normalize path: backslashes → slashes, remove duplicate and trailing slash
|
|
16
|
+
const key = p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '');
|
|
17
|
+
if (!storage.has(key)) throw new Error('ENOENT');
|
|
18
|
+
return storage.get(key);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
/** Adds a file or directory into the mock storage */
|
|
22
|
+
_add: (p, isFile) => {
|
|
23
|
+
const key = p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '');
|
|
24
|
+
storage.set(key, {
|
|
25
|
+
isFile: () => isFile,
|
|
26
|
+
isDirectory: () => !isFile
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function addFile(p) { mockFs._add(p, true); }
|
|
33
|
+
|
|
34
|
+
function addDir(p) { mockFs._add(p, false); }
|
|
35
|
+
|
|
36
|
+
/** Creates and returns a configured Fallback instance */
|
|
37
|
+
async function getFallback() {
|
|
38
|
+
const container = buildTestContainer();
|
|
39
|
+
container.register('node:fs', mockFs);
|
|
40
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Fallback} */
|
|
41
|
+
return container.get('Fl32_Web_Back_Handler_Static_A_Fallback$');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it('returns index file for a directory', async () => {
|
|
45
|
+
addDir('/d');
|
|
46
|
+
addFile('/d/index.html');
|
|
47
|
+
|
|
48
|
+
const fb = await getFallback();
|
|
49
|
+
const result = await fb.apply('/d', ['index.html']);
|
|
50
|
+
assert.strictEqual(result, '/d/index.html');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns null when nothing found', async () => {
|
|
54
|
+
addDir('/x');
|
|
55
|
+
|
|
56
|
+
const fb = await getFallback();
|
|
57
|
+
const result = await fb.apply('/x', ['a.html']);
|
|
58
|
+
assert.strictEqual(result, null);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { buildTestContainer } from '../../../../common.js';
|
|
5
|
+
|
|
6
|
+
const normalize = p => p.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '');
|
|
7
|
+
|
|
8
|
+
class MockRes extends EventEmitter {
|
|
9
|
+
constructor() {
|
|
10
|
+
super();
|
|
11
|
+
this.data = '';
|
|
12
|
+
this.status = undefined;
|
|
13
|
+
this.headers = undefined;
|
|
14
|
+
this._hs = false;
|
|
15
|
+
this._ended = false;
|
|
16
|
+
}
|
|
17
|
+
get headersSent() {
|
|
18
|
+
return this._hs;
|
|
19
|
+
}
|
|
20
|
+
get writableEnded() {
|
|
21
|
+
return this._ended;
|
|
22
|
+
}
|
|
23
|
+
writeHead(status, headers) {
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.headers = headers;
|
|
26
|
+
this._hs = true;
|
|
27
|
+
}
|
|
28
|
+
write(chunk) {
|
|
29
|
+
this.data += chunk;
|
|
30
|
+
}
|
|
31
|
+
end(chunk) {
|
|
32
|
+
if (chunk) this.write(chunk);
|
|
33
|
+
this._ended = true;
|
|
34
|
+
this.emit('finish');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('Fl32_Web_Back_Handler_Static_A_FileService', () => {
|
|
39
|
+
let storage, mockFs, mime, logger, logs, addFile, addDir;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
storage = new Map();
|
|
43
|
+
|
|
44
|
+
// mock fs.promises.stat and createReadStream
|
|
45
|
+
mockFs = {
|
|
46
|
+
promises: {
|
|
47
|
+
stat: async p => {
|
|
48
|
+
const key = normalize(p);
|
|
49
|
+
if (!storage.has(key)) throw new Error('ENOENT');
|
|
50
|
+
const entry = storage.get(key);
|
|
51
|
+
return {
|
|
52
|
+
isFile: entry.isFile,
|
|
53
|
+
isDirectory: entry.isDirectory,
|
|
54
|
+
size: entry.size,
|
|
55
|
+
mtime: entry.mtime
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
createReadStream: p => ({
|
|
60
|
+
pipe: res => {
|
|
61
|
+
setImmediate(() => {
|
|
62
|
+
const key = normalize(p);
|
|
63
|
+
const entry = storage.get(key);
|
|
64
|
+
if (entry && entry.content != null) res.write(entry.content);
|
|
65
|
+
res.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// simple mime helper
|
|
72
|
+
mime = {
|
|
73
|
+
getByExt: () => 'text/plain'
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
logs = [];
|
|
77
|
+
// simple logger collecting calls
|
|
78
|
+
logger = {
|
|
79
|
+
info: (...args) => logs.push(['info', ...args]),
|
|
80
|
+
warn: (...args) => logs.push(['warn', ...args]),
|
|
81
|
+
exception: (...args) => logs.push(['exception', ...args])
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// helpers to populate mock FS
|
|
85
|
+
addFile = (p, content) => {
|
|
86
|
+
const key = normalize(p);
|
|
87
|
+
storage.set(key, {
|
|
88
|
+
isFile: () => true,
|
|
89
|
+
isDirectory: () => false,
|
|
90
|
+
size: Buffer.byteLength(content),
|
|
91
|
+
mtime: new Date(),
|
|
92
|
+
content
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
addDir = p => {
|
|
96
|
+
const key = normalize(p);
|
|
97
|
+
storage.set(key, {
|
|
98
|
+
isFile: () => false,
|
|
99
|
+
isDirectory: () => true,
|
|
100
|
+
size: 0,
|
|
101
|
+
mtime: new Date()
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('serves existing file', async () => {
|
|
107
|
+
addFile('/root/a.txt', 'A');
|
|
108
|
+
|
|
109
|
+
/** @type {{ root: string, prefix: string, defaults: string[] }} */
|
|
110
|
+
const config = { root: '/root', prefix: '/p/', defaults: ['index.html'] };
|
|
111
|
+
const res = new MockRes();
|
|
112
|
+
|
|
113
|
+
const container = buildTestContainer();
|
|
114
|
+
container.register('node:fs', mockFs);
|
|
115
|
+
container.register('Fl32_Web_Back_Helper_Mime$', mime);
|
|
116
|
+
container.register('Fl32_Web_Back_Logger$', logger);
|
|
117
|
+
|
|
118
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_FileService} */
|
|
119
|
+
const service = await container.get('Fl32_Web_Back_Handler_Static_A_FileService$');
|
|
120
|
+
|
|
121
|
+
const ok = await service.serve(config, 'a.txt', {}, res);
|
|
122
|
+
await new Promise(r => res.on('finish', r));
|
|
123
|
+
|
|
124
|
+
assert.ok(ok);
|
|
125
|
+
assert.strictEqual(res.status, 200);
|
|
126
|
+
assert.strictEqual(res.data, 'A');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns false when file not found', async () => {
|
|
130
|
+
addDir('/root');
|
|
131
|
+
|
|
132
|
+
/** @type {{ root: string, prefix: string, defaults: string[] }} */
|
|
133
|
+
const config = { root: '/root', prefix: '/p/', defaults: ['index.html'] };
|
|
134
|
+
const res = new MockRes();
|
|
135
|
+
|
|
136
|
+
const container = buildTestContainer();
|
|
137
|
+
container.register('node:fs', mockFs);
|
|
138
|
+
container.register('Fl32_Web_Back_Helper_Mime$', mime);
|
|
139
|
+
container.register('Fl32_Web_Back_Logger$', logger);
|
|
140
|
+
|
|
141
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_FileService} */
|
|
142
|
+
const service = await container.get('Fl32_Web_Back_Handler_Static_A_FileService$');
|
|
143
|
+
|
|
144
|
+
const ok = await service.serve(config, 'missing.txt', {}, res);
|
|
145
|
+
assert.strictEqual(ok, false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('logs info when file is missing during stat', async () => {
|
|
149
|
+
mockFs.promises.stat = async () => {
|
|
150
|
+
const err = new Error('ENOENT');
|
|
151
|
+
err.code = 'ENOENT';
|
|
152
|
+
throw err;
|
|
153
|
+
};
|
|
154
|
+
/** @type {{ root: string, prefix: string, defaults: string[] }} */
|
|
155
|
+
const config = { root: '/root', prefix: '/p/', defaults: [] };
|
|
156
|
+
const res = new MockRes();
|
|
157
|
+
|
|
158
|
+
const container = buildTestContainer();
|
|
159
|
+
container.register('node:fs', mockFs);
|
|
160
|
+
container.register('Fl32_Web_Back_Helper_Mime$', mime);
|
|
161
|
+
container.register('Fl32_Web_Back_Logger$', logger);
|
|
162
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Fallback$', { apply: async p => p });
|
|
163
|
+
|
|
164
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_FileService} */
|
|
165
|
+
const service = await container.get('Fl32_Web_Back_Handler_Static_A_FileService$');
|
|
166
|
+
|
|
167
|
+
const ok = await service.serve(config, 'missing.txt', {}, res);
|
|
168
|
+
|
|
169
|
+
assert.strictEqual(ok, false);
|
|
170
|
+
assert.strictEqual(logs.length, 1);
|
|
171
|
+
assert.strictEqual(logs[0][0], 'info');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('logs warn on access errors', async () => {
|
|
175
|
+
/** force EACCES error */
|
|
176
|
+
mockFs.promises.stat = async () => {
|
|
177
|
+
const err = new Error('EACCES');
|
|
178
|
+
err.code = 'EACCES';
|
|
179
|
+
throw err;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** @type {{ root: string, prefix: string, defaults: string[] }} */
|
|
183
|
+
const config = { root: '/root', prefix: '/p/', defaults: [] };
|
|
184
|
+
const res = new MockRes();
|
|
185
|
+
|
|
186
|
+
const container = buildTestContainer();
|
|
187
|
+
container.register('node:fs', mockFs);
|
|
188
|
+
container.register('Fl32_Web_Back_Helper_Mime$', mime);
|
|
189
|
+
container.register('Fl32_Web_Back_Logger$', logger);
|
|
190
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Fallback$', { apply: async p => p });
|
|
191
|
+
|
|
192
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_FileService} */
|
|
193
|
+
const service = await container.get('Fl32_Web_Back_Handler_Static_A_FileService$');
|
|
194
|
+
|
|
195
|
+
const ok = await service.serve(config, 'denied.txt', {}, res);
|
|
196
|
+
|
|
197
|
+
assert.strictEqual(ok, false);
|
|
198
|
+
assert.strictEqual(logs.length, 1);
|
|
199
|
+
assert.strictEqual(logs[0][0], 'warn');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('logs exception on unexpected errors', async () => {
|
|
203
|
+
addFile('/root/x.txt', 'X');
|
|
204
|
+
mockFs.createReadStream = () => { throw new Error('boom'); };
|
|
205
|
+
|
|
206
|
+
/** @type {{ root: string, prefix: string, defaults: string[] }} */
|
|
207
|
+
const config = { root: '/root', prefix: '/p/', defaults: [] };
|
|
208
|
+
const res = new MockRes();
|
|
209
|
+
|
|
210
|
+
const container = buildTestContainer();
|
|
211
|
+
container.register('node:fs', mockFs);
|
|
212
|
+
container.register('Fl32_Web_Back_Helper_Mime$', mime);
|
|
213
|
+
container.register('Fl32_Web_Back_Logger$', logger);
|
|
214
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Fallback$', { apply: async p => p });
|
|
215
|
+
|
|
216
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_FileService} */
|
|
217
|
+
const service = await container.get('Fl32_Web_Back_Handler_Static_A_FileService$');
|
|
218
|
+
|
|
219
|
+
const ok = await service.serve(config, 'x.txt', {}, res);
|
|
220
|
+
|
|
221
|
+
assert.strictEqual(ok, false);
|
|
222
|
+
assert.strictEqual(logs.length, 1);
|
|
223
|
+
assert.strictEqual(logs[0][0], 'exception');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildTestContainer } from '../../../../common.js';
|
|
4
|
+
|
|
5
|
+
/** Simple config factory mock */
|
|
6
|
+
function getMockFactory() {
|
|
7
|
+
return { create: dto => ({ root: dto.root, prefix: dto.prefix }) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('Fl32_Web_Back_Handler_Static_A_Registry', () => {
|
|
11
|
+
it('stores initial config', async () => {
|
|
12
|
+
const container = buildTestContainer();
|
|
13
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Config$', getMockFactory());
|
|
14
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Registry} */
|
|
15
|
+
const registry = await container.get('Fl32_Web_Back_Handler_Static_A_Registry$');
|
|
16
|
+
|
|
17
|
+
registry.addConfigs([{ root: '/a', prefix: '/p/' }]);
|
|
18
|
+
const match = registry.find('/p/file.txt');
|
|
19
|
+
|
|
20
|
+
assert.ok(match);
|
|
21
|
+
assert.strictEqual(match.config.root, '/a');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('adds config with new prefix', async () => {
|
|
25
|
+
const container = buildTestContainer();
|
|
26
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Config$', getMockFactory());
|
|
27
|
+
const registry = await container.get('Fl32_Web_Back_Handler_Static_A_Registry$');
|
|
28
|
+
|
|
29
|
+
registry.addConfigs([{ root: '/a', prefix: '/p/' }]);
|
|
30
|
+
registry.addConfigs([{ root: '/b', prefix: '/p/s/' }]);
|
|
31
|
+
|
|
32
|
+
const match = registry.find('/p/s/file.txt');
|
|
33
|
+
assert.ok(match);
|
|
34
|
+
assert.strictEqual(match.config.root, '/b');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('ignores config with existing prefix', async () => {
|
|
38
|
+
const container = buildTestContainer();
|
|
39
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Config$', getMockFactory());
|
|
40
|
+
const registry = await container.get('Fl32_Web_Back_Handler_Static_A_Registry$');
|
|
41
|
+
|
|
42
|
+
registry.addConfigs([{ root: '/a', prefix: '/p/' }]);
|
|
43
|
+
registry.addConfigs([{ root: '/b', prefix: '/p/s/' }]);
|
|
44
|
+
registry.addConfigs([{ root: '/c', prefix: '/p/s/' }]);
|
|
45
|
+
|
|
46
|
+
const match = registry.find('/p/s/test.txt');
|
|
47
|
+
assert.ok(match);
|
|
48
|
+
assert.strictEqual(match.config.root, '/b');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('logs warning when prefix already exists', async () => {
|
|
52
|
+
const log = [];
|
|
53
|
+
const container = buildTestContainer();
|
|
54
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Config$', getMockFactory());
|
|
55
|
+
container.register('Fl32_Web_Back_Logger$', {
|
|
56
|
+
warn: (...args) => log.push(args)
|
|
57
|
+
});
|
|
58
|
+
const registry = await container.get('Fl32_Web_Back_Handler_Static_A_Registry$');
|
|
59
|
+
|
|
60
|
+
registry.addConfigs([{ root: '/a', prefix: '/p/' }]);
|
|
61
|
+
registry.addConfigs([{ root: '/b', prefix: '/p/' }]);
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(log.length, 1);
|
|
64
|
+
assert.ok(log[0][0].includes('/p/'));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('prefers longer prefix when matching', async () => {
|
|
68
|
+
const container = buildTestContainer();
|
|
69
|
+
container.register('Fl32_Web_Back_Handler_Static_A_Config$', getMockFactory());
|
|
70
|
+
const registry = await container.get('Fl32_Web_Back_Handler_Static_A_Registry$');
|
|
71
|
+
|
|
72
|
+
registry.addConfigs([
|
|
73
|
+
{ root: '/a', prefix: '/p/' },
|
|
74
|
+
{ root: '/b', prefix: '/p/s/' }
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const match1 = registry.find('/p/s/x.txt');
|
|
78
|
+
const match2 = registry.find('/p/y.txt');
|
|
79
|
+
|
|
80
|
+
assert.strictEqual(match1.config.root, '/b');
|
|
81
|
+
assert.strictEqual(match2.config.root, '/a');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildTestContainer } from '../../../../common.js';
|
|
4
|
+
|
|
5
|
+
describe('Fl32_Web_Back_Handler_Static_A_Resolver', () => {
|
|
6
|
+
it('resolves allowed path', async () => {
|
|
7
|
+
const container = buildTestContainer();
|
|
8
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
9
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
10
|
+
|
|
11
|
+
const { resolve } = await import('node:path');
|
|
12
|
+
const config = {
|
|
13
|
+
root: resolve('/root'),
|
|
14
|
+
prefix: '/p/',
|
|
15
|
+
allow: { pkg: ['a.txt'] }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const fsPath = resolver.resolve(config, 'pkg/a.txt');
|
|
19
|
+
assert.strictEqual(fsPath, resolve('/root/pkg/a.txt'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null for disallowed path', async () => {
|
|
23
|
+
const container = buildTestContainer();
|
|
24
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
25
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
26
|
+
|
|
27
|
+
const { resolve } = await import('node:path');
|
|
28
|
+
const config = {
|
|
29
|
+
root: resolve('/root'),
|
|
30
|
+
prefix: '/p/',
|
|
31
|
+
allow: { pkg: ['a.txt'] }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const result = resolver.resolve(config, 'pkg/b.txt');
|
|
35
|
+
assert.strictEqual(result, null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('throws on path traversal attempts', async () => {
|
|
39
|
+
const container = buildTestContainer();
|
|
40
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
41
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
42
|
+
|
|
43
|
+
const { resolve } = await import('node:path');
|
|
44
|
+
const config = {
|
|
45
|
+
root: resolve('/root'),
|
|
46
|
+
prefix: '/p/',
|
|
47
|
+
allow: { pkg: ['a.txt'] }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => resolver.resolve(config, '../x'),
|
|
52
|
+
/Static access denied/
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('throws on absolute rel paths', async () => {
|
|
57
|
+
const container = buildTestContainer();
|
|
58
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
59
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
60
|
+
|
|
61
|
+
const { resolve } = await import('node:path');
|
|
62
|
+
const config = {
|
|
63
|
+
root: resolve('/root'),
|
|
64
|
+
prefix: '/p/',
|
|
65
|
+
allow: { pkg: ['a.txt'] }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => resolver.resolve(config, '/etc/passwd'),
|
|
70
|
+
/Static access denied/
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|