@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.
@@ -0,0 +1,235 @@
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
+ /** Simple HTTP/2 constants mock */
7
+ const mockHttp2 = {
8
+ constants: {
9
+ HTTP2_HEADER_CONTENT_LENGTH: 'content-length',
10
+ HTTP2_HEADER_CONTENT_TYPE: 'content-type',
11
+ HTTP2_HEADER_LAST_MODIFIED: 'last-modified',
12
+ HTTP_STATUS_OK: 200
13
+ }
14
+ };
15
+
16
+ /** Minimal path mock for FS key normalization */
17
+ const mockPath = {
18
+ resolve: (...parts) => parts.join('/').replace(/\/+/g, '/'),
19
+ join: (...parts) => parts.join('/').replace(/\/+/g, '/'),
20
+ isAbsolute: p => p.startsWith('/'),
21
+ extname: p => {
22
+ const m = p.match(/(\.[^./]+)$/);
23
+ return m ? m[1] : '';
24
+ }
25
+ };
26
+
27
+ /** In-memory FS storage */
28
+ let storage;
29
+ let mockFs;
30
+
31
+ function resetFs() {
32
+ storage = new Map();
33
+ }
34
+
35
+ function addFile(p, content) {
36
+ const key = mockPath.resolve(p);
37
+ storage.set(key, {
38
+ isFile: () => true,
39
+ isDirectory: () => false,
40
+ size: Buffer.byteLength(content),
41
+ mtime: new Date(),
42
+ content
43
+ });
44
+ }
45
+
46
+ function addDir(p) {
47
+ const key = mockPath.resolve(p);
48
+ storage.set(key, {
49
+ isFile: () => false,
50
+ isDirectory: () => true,
51
+ size: 0,
52
+ mtime: new Date(),
53
+ content: null
54
+ });
55
+ }
56
+
57
+ beforeEach(() => {
58
+ resetFs();
59
+ mockFs = {
60
+ promises: {
61
+ stat: async p => {
62
+ const key = mockPath.resolve(p);
63
+ if (!storage.has(key)) throw new Error('ENOENT');
64
+ const entry = storage.get(key);
65
+ return {
66
+ isFile: entry.isFile,
67
+ isDirectory: entry.isDirectory,
68
+ size: entry.size,
69
+ mtime: entry.mtime
70
+ };
71
+ }
72
+ },
73
+ createReadStream: p => ({
74
+ pipe: res => {
75
+ setImmediate(() => {
76
+ const entry = storage.get(mockPath.resolve(p));
77
+ if (entry && entry.content != null) res.write(entry.content);
78
+ res.end();
79
+ });
80
+ }
81
+ })
82
+ };
83
+ });
84
+
85
+ /** Mock response with writable stream semantics */
86
+ class MockRes extends EventEmitter {
87
+ constructor() {
88
+ super();
89
+ this.data = Buffer.alloc(0);
90
+ this.statusCode = undefined;
91
+ this.headers = undefined;
92
+ this._sent = false;
93
+ this._ended = false;
94
+ }
95
+
96
+ get headersSent() { return this._sent; }
97
+
98
+ get writableEnded() { return this._ended; }
99
+
100
+ writeHead(status, headers) {
101
+ this.statusCode = status;
102
+ this.headers = headers;
103
+ this._sent = true;
104
+ }
105
+
106
+ write(chunk) {
107
+ this.data = Buffer.concat([this.data, Buffer.from(chunk)]);
108
+ }
109
+
110
+ end(chunk) {
111
+ if (chunk) this.write(chunk);
112
+ this._ended = true;
113
+ this.emit('finish');
114
+ }
115
+ }
116
+
117
+ describe('Fl32_Web_Back_Handler_Static', () => {
118
+ let container;
119
+
120
+ beforeEach(() => {
121
+ container = buildTestContainer();
122
+ container.register('node:fs', mockFs);
123
+ container.register('node:http2', mockHttp2);
124
+ container.register('node:path', mockPath);
125
+ container.register('Fl32_Web_Back_Logger$', {
126
+ warn: () => {},
127
+ exception: () => {}
128
+ });
129
+ });
130
+
131
+ it('serves from the most specific source', async () => {
132
+ addDir('/a');
133
+ addFile('/a/test.txt', 'A');
134
+ addDir('/b');
135
+ addFile('/b/test.txt', 'B');
136
+
137
+ /** @type {Fl32_Web_Back_Dto_Handler_Source} */
138
+ const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
139
+ /** @type {Fl32_Web_Back_Handler_Static} */
140
+ const handler = await container.get('Fl32_Web_Back_Handler_Static$');
141
+
142
+ await handler.init({
143
+ sources: [
144
+ dtoSource.create({prefix: '/files/', root: '/a', allow: {'.': ['.']}, defaults: []}),
145
+ dtoSource.create({prefix: '/files/special/', root: '/b', allow: {'.': ['.']}, defaults: []})
146
+ ]
147
+ });
148
+
149
+ const res = new MockRes();
150
+ const ok = await handler.handle({url: '/files/special/test.txt'}, res);
151
+ await new Promise(r => res.on('finish', r));
152
+
153
+ assert.strictEqual(ok, true);
154
+ assert.strictEqual(res.data.toString(), 'B');
155
+ });
156
+
157
+ it('enforces allow-list rules', async () => {
158
+ addFile('src/Back/Server.js', 'class X {}');
159
+ addFile('src/Back/Handler/Static.js', 'ignore');
160
+
161
+ /** @type {Fl32_Web_Back_Dto_Handler_Source} */
162
+ const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
163
+ /** @type {Fl32_Web_Back_Handler_Static} */
164
+ const handler = await container.get('Fl32_Web_Back_Handler_Static$');
165
+
166
+ await handler.init({
167
+ sources: [
168
+ dtoSource.create({prefix: '/s/', root: 'src', allow: {Back: ['Server.js']}, defaults: []})
169
+ ]
170
+ });
171
+
172
+ const okRes = new MockRes();
173
+ const ok = await handler.handle({url: '/s/Back/Server.js'}, okRes);
174
+ await new Promise(r => okRes.on('finish', r));
175
+ assert.strictEqual(ok, true);
176
+
177
+ const badRes = new MockRes();
178
+ const bad = await handler.handle({url: '/s/Back/Handler/Static.js'}, badRes);
179
+ assert.strictEqual(bad, false);
180
+ assert.strictEqual(badRes.headersSent, false);
181
+ });
182
+
183
+ it('serves index files in directories', async () => {
184
+ addDir('/dir');
185
+ addDir('/dir/d');
186
+ addFile('/dir/d/index.txt', 'INDEX');
187
+
188
+ /** @type {Fl32_Web_Back_Dto_Handler_Source} */
189
+ const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
190
+ /** @type {Fl32_Web_Back_Handler_Static} */
191
+ const handler = await container.get('Fl32_Web_Back_Handler_Static$');
192
+
193
+ await handler.init({
194
+ sources: [
195
+ dtoSource.create({
196
+ prefix: '/w/',
197
+ root: '/dir',
198
+ allow: {'.': ['.']},
199
+ defaults: ['index.txt']
200
+ })
201
+ ]
202
+ });
203
+
204
+ const res = new MockRes();
205
+ const ok = await handler.handle({url: '/w/d/'}, res);
206
+ await new Promise(r => res.on('finish', r));
207
+
208
+ assert.strictEqual(ok, true);
209
+ assert.strictEqual(res.data.toString(), 'INDEX');
210
+ });
211
+
212
+ it('rejects traversal and unmatched prefixes', async () => {
213
+ addFile('/safe/file.txt', 'ok');
214
+
215
+ /** @type {Fl32_Web_Back_Dto_Handler_Source} */
216
+ const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
217
+ /** @type {Fl32_Web_Back_Handler_Static} */
218
+ const handler = await container.get('Fl32_Web_Back_Handler_Static$');
219
+
220
+ await handler.init({
221
+ sources: [
222
+ dtoSource.create({prefix: '/p/', root: '/safe', allow: {'.': ['.']}, defaults: []})
223
+ ]
224
+ });
225
+
226
+ const res1 = new MockRes();
227
+ const bad1 = await handler.handle({url: '/p/../file.txt'}, res1);
228
+ assert.strictEqual(bad1, false);
229
+
230
+ const res2 = new MockRes();
231
+ const bad2 = await handler.handle({url: '/x/file.txt'}, res2);
232
+ assert.strictEqual(bad2, false);
233
+ assert.strictEqual(res2.headersSent, false);
234
+ });
235
+ });
@@ -0,0 +1,83 @@
1
+ import {describe, it, beforeEach} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {buildTestContainer} from '../../common.js';
4
+
5
+ /** Minimal HTTP/2 constants mock */
6
+ const mockHttp2 = {
7
+ constants: {
8
+ HTTP2_HEADER_ALLOW: 'allow',
9
+ HTTP_STATUS_OK: 200,
10
+ HTTP_STATUS_CREATED: 201,
11
+ HTTP_STATUS_NO_CONTENT: 204,
12
+ HTTP_STATUS_MOVED_PERMANENTLY: 301,
13
+ HTTP_STATUS_FOUND: 302,
14
+ HTTP_STATUS_SEE_OTHER: 303,
15
+ HTTP_STATUS_NOT_MODIFIED: 304,
16
+ HTTP_STATUS_BAD_REQUEST: 400,
17
+ HTTP_STATUS_UNAUTHORIZED: 401,
18
+ HTTP_STATUS_PAYMENT_REQUIRED: 402,
19
+ HTTP_STATUS_FORBIDDEN: 403,
20
+ HTTP_STATUS_NOT_FOUND: 404,
21
+ HTTP_STATUS_METHOD_NOT_ALLOWED: 405,
22
+ HTTP_STATUS_CONFLICT: 409,
23
+ HTTP_STATUS_INTERNAL_SERVER_ERROR: 500,
24
+ HTTP_STATUS_BAD_GATEWAY: 502,
25
+ HTTP_STATUS_SERVICE_UNAVAILABLE: 503,
26
+ }
27
+ };
28
+
29
+ class MockRes {
30
+ constructor() {
31
+ this.statusCode = undefined;
32
+ this.headers = undefined;
33
+ this.body = undefined;
34
+ this.headersSent = false;
35
+ this.writableEnded = false;
36
+ }
37
+
38
+ writeHead(status, headers) {
39
+ this.statusCode = status;
40
+ this.headers = headers;
41
+ this.headersSent = true;
42
+ }
43
+
44
+ end(chunk = '') {
45
+ this.body = chunk;
46
+ this.writableEnded = true;
47
+ }
48
+ }
49
+
50
+ describe('Fl32_Web_Back_Helper_Respond', () => {
51
+ let container;
52
+ let respond;
53
+
54
+ beforeEach(async () => {
55
+ container = buildTestContainer();
56
+ container.register('node:http2', mockHttp2);
57
+ respond = await container.get('Fl32_Web_Back_Helper_Respond$');
58
+ });
59
+
60
+ it('sends 200 OK response', () => {
61
+ const res = new MockRes();
62
+ const ok = respond.code200_Ok({res, headers: {a: 'b'}, body: 'hi'});
63
+ assert.strictEqual(ok, true);
64
+ assert.strictEqual(res.statusCode, 200);
65
+ assert.deepStrictEqual(res.headers, {a: 'b'});
66
+ assert.strictEqual(res.body, 'hi');
67
+ });
68
+
69
+ it('adds Allow header for 405 Method Not Allowed', () => {
70
+ const res = new MockRes();
71
+ respond.code405_MethodNotAllowed({res});
72
+ assert.strictEqual(res.statusCode, 405);
73
+ assert.strictEqual(res.headers.allow, 'HEAD, GET, POST');
74
+ });
75
+
76
+ it('isWritable detects ended responses', () => {
77
+ const res = new MockRes();
78
+ respond.code200_Ok({res});
79
+ assert.strictEqual(respond.isWritable(res), false);
80
+ const again = respond.code200_Ok({res});
81
+ assert.strictEqual(again, false);
82
+ });
83
+ });
@@ -1,161 +0,0 @@
1
- /**
2
- * Serves whitelisted files from ./node_modules directory.
3
- * @implements Fl32_Web_Back_Api_Handler
4
- */
5
- export default class Fl32_Web_Back_Handler_Npm {
6
- /* eslint-disable jsdoc/require-param-description,jsdoc/check-param-names */
7
- /**
8
- * @param {typeof import('node:fs')} fs
9
- * @param {typeof import('node:http2')} http2
10
- * @param {typeof import('node:path')} path
11
- * @param {Fl32_Web_Back_Logger} logger
12
- * @param {Fl32_Web_Back_Helper_Mime} helpMime
13
- * @param {Fl32_Web_Back_Helper_Respond} respond
14
- * @param {Fl32_Web_Back_Dto_Handler_Info} dtoInfo
15
- * @param {typeof Fl32_Web_Back_Enum_Stage} STAGE
16
- */
17
- constructor(
18
- {
19
- 'node:fs': fs,
20
- 'node:http2': http2,
21
- 'node:path': path,
22
- Fl32_Web_Back_Logger$: logger,
23
- Fl32_Web_Back_Helper_Mime$: helpMime,
24
- Fl32_Web_Back_Helper_Respond$: respond,
25
- Fl32_Web_Back_Dto_Handler_Info$: dtoInfo,
26
- Fl32_Web_Back_Enum_Stage$: STAGE,
27
- }
28
- ) {
29
- /* eslint-enable jsdoc/check-param-names */
30
- // VARS
31
- const {promises: fsp} = fs;
32
- const {constants: H2} = http2;
33
- const {
34
- HTTP2_HEADER_CONTENT_LENGTH,
35
- HTTP2_HEADER_CONTENT_TYPE,
36
- HTTP2_HEADER_LAST_MODIFIED,
37
- HTTP_STATUS_OK,
38
- } = H2;
39
- const _root = path.resolve('node_modules');
40
-
41
- /** @type {Fl32_Web_Back_Dto_Handler_Info.Dto} */
42
- const _info = dtoInfo.create();
43
- _info.name = this.constructor.name;
44
- _info.stage = STAGE.PROCESS;
45
- Object.freeze(_info);
46
-
47
- /** @type {{[key: string]: string[]}} */
48
- let _allow = {};
49
-
50
- // MAIN
51
- /**
52
- * Handles request to serve allowed files from node_modules.
53
- *
54
- * @param {module:http.IncomingMessage|module:http2.Http2ServerRequest} req
55
- * @param {module:http.ServerResponse|module:http2.Http2ServerResponse} res
56
- * @returns {Promise<boolean>}
57
- */
58
- this.handle = async function (req, res) {
59
- if (!respond.isWritable(res)) return false;
60
-
61
- const urlPath = decodeURIComponent(req.url.split('?')[0]);
62
- const prefix = '/node_modules/';
63
- if (!urlPath.startsWith(prefix)) return false;
64
-
65
- const rel = urlPath.slice(prefix.length);
66
- if (rel.includes('..') || path.isAbsolute(rel)) {
67
- logger.warn(`NPM static access denied: ${rel}`);
68
- return false;
69
- }
70
-
71
- let pkg; let subPath;
72
- for (const key of Object.keys(_allow)) {
73
- if (rel === key || rel.startsWith(`${key}/`)) {
74
- pkg = key;
75
- subPath = rel.slice(key.length);
76
- if (subPath.startsWith('/')) subPath = subPath.slice(1);
77
- break;
78
- }
79
- }
80
- if (!pkg) return false;
81
-
82
- const rules = _allow[pkg] || [];
83
- let allowed = false;
84
- if (rules.includes('.')) {
85
- allowed = true;
86
- } else {
87
- for (const p of rules) {
88
- if (subPath === p || subPath.startsWith(`${p}/`)) {
89
- allowed = true;
90
- break;
91
- }
92
- }
93
- }
94
-
95
- if (!allowed) {
96
- logger.warn(`NPM static access denied: ${rel}`);
97
- return false;
98
- }
99
-
100
- const fsPath = path.resolve(_root, rel);
101
- if (!fsPath.startsWith(_root)) {
102
- logger.warn(`NPM static access denied: ${rel}`);
103
- return false;
104
- }
105
-
106
- let stat;
107
- try {
108
- stat = await fsp.stat(fsPath);
109
- } catch {
110
- logger.warn(`NPM static file not found: ${rel}`);
111
- return false;
112
- }
113
- if (!stat.isFile()) {
114
- logger.warn(`NPM static file not found: ${rel}`);
115
- return false;
116
- }
117
-
118
- const stream = fs.createReadStream(fsPath);
119
- const ext = path.extname(fsPath).toLowerCase();
120
- const headers = {
121
- [HTTP2_HEADER_CONTENT_LENGTH]: stat.size,
122
- [HTTP2_HEADER_CONTENT_TYPE]: helpMime.getByExt(ext),
123
- [HTTP2_HEADER_LAST_MODIFIED]: stat.mtime.toUTCString(),
124
- };
125
- res.writeHead(HTTP_STATUS_OK, headers);
126
- stream.pipe(res);
127
- return true;
128
- };
129
-
130
- /**
131
- * Initialize handler with allow list.
132
- *
133
- * @param {object} params
134
- * @param {{[key: string]: string[]}} params.allow - Map of packages to paths
135
- * that can be served from `node_modules`.
136
- * @returns {Promise<void>}
137
- *
138
- * @example
139
- * // Allow a single file from a package
140
- * await npmHandler.init({
141
- * allow: {
142
- * vue: ['dist/vue.global.prod.js'],
143
- * },
144
- * });
145
- *
146
- * @example
147
- * // Allow all files from a subfolder
148
- * await npmHandler.init({
149
- * allow: {
150
- * '@teqfw/di/src': ['.'],
151
- * },
152
- * });
153
- */
154
- this.init = async function ({allow}) {
155
- _allow = allow || {};
156
- };
157
-
158
- /** @returns {Fl32_Web_Back_Dto_Handler_Info.Dto} */
159
- this.getRegistrationInfo = () => _info;
160
- }
161
- }
@@ -1,69 +0,0 @@
1
- import {describe, it, beforeEach} from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {Writable} from 'node:stream';
4
- import {buildTestContainer} from '../../common.js';
5
-
6
- class MockRes extends Writable {
7
- constructor() {
8
- super();
9
- this.data = Buffer.alloc(0);
10
- this.statusCode = undefined;
11
- this.headers = undefined;
12
- this._headersSent = false;
13
- this._ended = false;
14
- }
15
- get headersSent() { return this._headersSent; }
16
- get writableEnded() { return this._ended; }
17
- writeHead(status, headers) {
18
- this.statusCode = status;
19
- this.headers = headers;
20
- this._headersSent = true;
21
- }
22
- _write(chunk, enc, cb) {
23
- this.data = Buffer.concat([this.data, chunk]);
24
- cb();
25
- }
26
- end(chunk) {
27
- if (chunk) this.data = Buffer.concat([this.data, Buffer.from(chunk)]);
28
- this._ended = true;
29
- super.end();
30
- }
31
- }
32
-
33
- describe('Fl32_Web_Back_Handler_Npm', () => {
34
- let container;
35
- const log = [];
36
-
37
- beforeEach(() => {
38
- container = buildTestContainer();
39
- container.register('Fl32_Web_Back_Logger$', {
40
- warn: (...args) => log.push(['warn', ...args]),
41
- exception: (...args) => log.push(['exception', ...args]),
42
- });
43
- log.length = 0;
44
- });
45
-
46
- it('should serve allowed file', async () => {
47
- const handler = await container.get('Fl32_Web_Back_Handler_Npm$');
48
- await handler.init({allow: {'@teqfw/di': ['package.json']}});
49
- const req = {url: '/node_modules/@teqfw/di/package.json'};
50
- const res = new MockRes();
51
- const ok = await handler.handle(req, res);
52
- await new Promise(resolve => res.on('finish', resolve));
53
- assert.strictEqual(ok, true);
54
- assert.strictEqual(res.statusCode, 200);
55
- assert.match(res.data.toString(), /@teqfw\/di/);
56
- assert.strictEqual(log.length, 0);
57
- });
58
-
59
- it('should deny disallowed path', async () => {
60
- const handler = await container.get('Fl32_Web_Back_Handler_Npm$');
61
- await handler.init({allow: {'@teqfw/di': ['package.json']}});
62
- const req = {url: '/node_modules/@teqfw/di/secret.js'};
63
- const res = new MockRes();
64
- const ok = await handler.handle(req, res);
65
- assert.strictEqual(ok, false);
66
- assert.strictEqual(res.headersSent, false);
67
- assert.ok(log[0][0] === 'warn');
68
- });
69
- });