@code2rich/jpage 1.5.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 (143) hide show
  1. package/.claude/settings.local.json +68 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +56 -0
  4. package/.github/workflows/ci.yml +43 -0
  5. package/CLAUDE.md +280 -0
  6. package/Dockerfile +44 -0
  7. package/LICENSE +21 -0
  8. package/README.md +433 -0
  9. package/README_EN.md +399 -0
  10. package/bin/args.js +64 -0
  11. package/bin/client.js +93 -0
  12. package/bin/commands/_shared.js +54 -0
  13. package/bin/commands/cat.js +23 -0
  14. package/bin/commands/ls.js +44 -0
  15. package/bin/commands/mv.js +20 -0
  16. package/bin/commands/rm.js +22 -0
  17. package/bin/commands/skills.js +70 -0
  18. package/bin/commands/star.js +23 -0
  19. package/bin/commands/tags.js +97 -0
  20. package/bin/commands/upload.js +84 -0
  21. package/bin/commands/url.js +25 -0
  22. package/bin/commands/whoami.js +29 -0
  23. package/bin/config.js +85 -0
  24. package/bin/jpage.js +168 -0
  25. package/build.js +112 -0
  26. package/docker-compose.yml +26 -0
  27. package/docs/api.md +438 -0
  28. package/docs/design/005-custom-modal.md +296 -0
  29. package/docs/design/013-file-version-history.md +324 -0
  30. package/docs/design/billing-system.md +600 -0
  31. package/docs/design/db-index-and-healthcheck.md +176 -0
  32. package/docs/design/loading-states.md +209 -0
  33. package/docs/virtual-hosting-feasibility.md +453 -0
  34. package/eslint.config.mjs +172 -0
  35. package/lib/auth-state.js +15 -0
  36. package/lib/categories.js +20 -0
  37. package/lib/crypto.js +85 -0
  38. package/lib/csp.js +66 -0
  39. package/lib/db.js +53 -0
  40. package/lib/dispatch.js +103 -0
  41. package/lib/fts.js +81 -0
  42. package/lib/middleware/auth.js +114 -0
  43. package/lib/middleware/files.js +42 -0
  44. package/lib/paths.js +9 -0
  45. package/lib/render-cache.js +48 -0
  46. package/lib/render.js +157 -0
  47. package/lib/templates.js +149 -0
  48. package/lib/util.js +66 -0
  49. package/lib/view-counts.js +59 -0
  50. package/lib/zip.js +192 -0
  51. package/logger.js +16 -0
  52. package/mailer.js +34 -0
  53. package/mcp/constants.js +16 -0
  54. package/mcp/resources.js +74 -0
  55. package/mcp/server.js +43 -0
  56. package/mcp/tools-categories.js +56 -0
  57. package/mcp/tools-content-templates.js +59 -0
  58. package/mcp/tools-files.js +245 -0
  59. package/mcp/tools-tags.js +41 -0
  60. package/mcp/tools-versions.js +57 -0
  61. package/mcp/transport.js +183 -0
  62. package/mcp/util.js +63 -0
  63. package/mcp-server.js +20 -0
  64. package/migrations/001_init_schema.js +25 -0
  65. package/migrations/002_add_share_key.js +33 -0
  66. package/migrations/003_add_roles_and_tokens.js +28 -0
  67. package/migrations/004_add_version_history.js +32 -0
  68. package/migrations/005_tags_starred_categories.js +49 -0
  69. package/migrations/006_zip_bundle.js +17 -0
  70. package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
  71. package/migrations/008_add_fts5.js +6 -0
  72. package/migrations/009_add_link_visits.js +20 -0
  73. package/migrations/010_add_templates_system.js +34 -0
  74. package/migrations/011_content_templates.js +233 -0
  75. package/migrations/012_add_email_and_verification.js +35 -0
  76. package/migrations/013_add_token_encrypted.js +14 -0
  77. package/migrations.js +65 -0
  78. package/package.json +63 -0
  79. package/public/css/style.css +2915 -0
  80. package/public/index.html +855 -0
  81. package/public/js/api.js +22 -0
  82. package/public/js/app.js +94 -0
  83. package/public/js/components/dialog.js +106 -0
  84. package/public/js/components/toast.js +13 -0
  85. package/public/js/pages/content-templates.js +330 -0
  86. package/public/js/pages/home.js +1903 -0
  87. package/public/js/pages/landing.js +158 -0
  88. package/public/js/pages/login.js +175 -0
  89. package/public/js/pages/preview.js +713 -0
  90. package/public/js/theme.js +44 -0
  91. package/public/js/utils.js +67 -0
  92. package/routes/admin.js +136 -0
  93. package/routes/auth.js +365 -0
  94. package/routes/categories.js +90 -0
  95. package/routes/content-templates.js +215 -0
  96. package/routes/files/_shared.js +112 -0
  97. package/routes/files/associations.js +94 -0
  98. package/routes/files/crud.js +139 -0
  99. package/routes/files/detail-serve.js +178 -0
  100. package/routes/files/index.js +38 -0
  101. package/routes/files/list.js +200 -0
  102. package/routes/files/overwrite.js +114 -0
  103. package/routes/files/upload.js +204 -0
  104. package/routes/files/versions.js +166 -0
  105. package/routes/files.js +16 -0
  106. package/routes/skills.js +93 -0
  107. package/routes/tags.js +65 -0
  108. package/routes/tokens.js +110 -0
  109. package/routes/users.js +120 -0
  110. package/server.js +372 -0
  111. package/skills/jpage-content-template/SKILL.md +98 -0
  112. package/skills/jpage-upload/SKILL.md +247 -0
  113. package/skills-registry.js +135 -0
  114. package/templates/academic.html +41 -0
  115. package/templates/dark-pro.html +41 -0
  116. package/templates/default.html +56 -0
  117. package/templates/github.html +67 -0
  118. package/test/browser-harness.js +125 -0
  119. package/test/dispatch-bench.js +74 -0
  120. package/test/helpers/setup.js +45 -0
  121. package/test/integration/admin.test.js +108 -0
  122. package/test/integration/auth.test.js +93 -0
  123. package/test/integration/categories.test.js +103 -0
  124. package/test/integration/cli.test.js +310 -0
  125. package/test/integration/content-templates.test.js +147 -0
  126. package/test/integration/files-security.test.js +248 -0
  127. package/test/integration/files.test.js +139 -0
  128. package/test/integration/share.test.js +79 -0
  129. package/test/integration/skills.test.js +104 -0
  130. package/test/integration/tags.test.js +84 -0
  131. package/test/integration/tokens.test.js +89 -0
  132. package/test/integration/users.test.js +138 -0
  133. package/test/mcp-harness.js +152 -0
  134. package/test/perf-bench.js +108 -0
  135. package/test/perf-harness.js +198 -0
  136. package/test/run-server.sh +15 -0
  137. package/test/unit/cli-args.test.js +88 -0
  138. package/test/unit/cli-config.test.js +89 -0
  139. package/test/unit/crypto.test.js +100 -0
  140. package/test/unit/fts.test.js +52 -0
  141. package/test/unit/render-cache.test.js +76 -0
  142. package/test/unit/util.test.js +81 -0
  143. package/test/unit/zip.test.js +164 -0
@@ -0,0 +1,198 @@
1
+ // 端到端验证套件:覆盖鉴权、上传、列表、渲染、搜索、短链、版本、MCP、缓存头
2
+ // 用法: node test/perf-harness.js [PORT]
3
+ // 退出码 0 = 全部通过, 非 0 = 有失败
4
+ const http = require('http');
5
+ const crypto = require('crypto');
6
+
7
+ const PORT = parseInt(process.argv[2] || process.env.PORT || '8890', 10);
8
+ const HOST = '127.0.0.1';
9
+ const ADMIN = 'admin';
10
+ const PASS = 'testpassword123';
11
+
12
+ let pass = 0, fail = 0;
13
+ const failures = [];
14
+
15
+ function check(name, cond, detail) {
16
+ if (cond) { pass++; console.log(` ✓ ${name}`); }
17
+ else { fail++; failures.push(`${name}${detail ? ' :: ' + detail : ''}`); console.log(` ✗ ${name}${detail ? ' :: ' + detail : ''}`); }
18
+ }
19
+
20
+ function req(method, path, { body, headers = {}, raw, formData } = {}) {
21
+ return new Promise((resolve, reject) => {
22
+ const opts = { host: HOST, port: PORT, method, path, headers: { ...headers } };
23
+ let payload = null;
24
+ if (raw !== undefined) { payload = raw; if (!opts.headers['Content-Type']) opts.headers['Content-Type'] = 'application/json'; }
25
+ else if (body !== undefined) { payload = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
26
+ else if (formData !== undefined) { payload = formData.body; Object.assign(opts.headers, formData.headers); }
27
+ if (payload) opts.headers['Content-Length'] = Buffer.byteLength(payload);
28
+ const r = http.request(opts, res => {
29
+ const chunks = [];
30
+ res.on('data', c => chunks.push(c));
31
+ res.on('end', () => {
32
+ const buf = Buffer.concat(chunks);
33
+ resolve({ status: res.statusCode, headers: res.headers, buf, text: buf.toString('utf8') });
34
+ });
35
+ });
36
+ r.on('error', reject);
37
+ if (payload) r.write(payload);
38
+ r.end();
39
+ });
40
+ }
41
+
42
+ // 多部分表单:单字段 file
43
+ function multipart(boundary, field) {
44
+ const CRLF = '\r\n';
45
+ const pre = `--${boundary}${CRLF}Content-Disposition: form-data; name="file"; filename="${field.filename}"${CRLF}Content-Type: ${field.type || 'text/markdown'}${CRLF}${CRLF}`;
46
+ const post = `${CRLF}--${boundary}--${CRLF}`;
47
+ const body = Buffer.concat([Buffer.from(pre, 'utf8'), Buffer.from(field.content, 'utf8'), Buffer.from(post, 'utf8')]);
48
+ return { body, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` } };
49
+ }
50
+
51
+ async function run() {
52
+ console.log(`\n=== jpage 验证套件 (port ${PORT}) ===\n`);
53
+
54
+ // 1. 健康检查
55
+ let r = await req('GET', '/health');
56
+ check('GET /health 200', r.status === 200, `status=${r.status}`);
57
+ check('health 报告 db ok', (() => { try { return JSON.parse(r.text).db === true; } catch { return false; } })());
58
+
59
+ // 2. 未登录访问受保护端点 → 401
60
+ r = await req('GET', '/api/auth/me');
61
+ check('未登录 GET /api/auth/me → 401', r.status === 401, `status=${r.status}`);
62
+
63
+ // 3. 登录
64
+ r = await req('POST', '/api/auth/login', { body: { username: ADMIN, password: PASS } });
65
+ check('登录 admin → 200', r.status === 200, `status=${r.status}`);
66
+ const cookie = (r.headers['set-cookie'] || []).map(c => c.split(';')[0]).join('; ');
67
+ check('登录返回 cookie', !!cookie);
68
+ const auth = { Cookie: cookie };
69
+ let me;
70
+ try { me = JSON.parse(r.text); } catch { me = {}; }
71
+ check('登录响应含 id/username/role', !!me.id && !!me.username && me.role === 'admin', JSON.stringify(me));
72
+
73
+ // 4. /api/auth/me
74
+ r = await req('GET', '/api/auth/me', { headers: auth });
75
+ check('GET /api/auth/me → 200 (带 cookie)', r.status === 200, `status=${r.status}`);
76
+
77
+ // 5. 上传 Markdown (JSON)
78
+ const mdContent = '# 标题\n\n```js\nconsole.log("hi");\n```\n\n行内公式 $a^2+b^2=c^2$\n\n搜索关键词 jpage_unique_token_alpha';
79
+ r = await req('POST', '/api/files/upload-json', { headers: auth, body: { name: 'perf-test.md', content: mdContent, isPublic: true } });
80
+ check('upload-json markdown → 200', r.status === 200, `status=${r.status} ${r.text}`);
81
+ let upload = {};
82
+ try { upload = JSON.parse(r.text); } catch {}
83
+ check('上传返回 id + share_key', !!upload.id && !!upload.share_key, r.text);
84
+ const fileId = upload.id;
85
+ const shareKey = upload.share_key;
86
+
87
+ // 6. 列表
88
+ r = await req('GET', '/api/files', { headers: auth });
89
+ check('GET /api/files → 200', r.status === 200, `status=${r.status}`);
90
+ let list = {};
91
+ try { list = JSON.parse(r.text); } catch {}
92
+ check('列表含刚上传文件', Array.isArray(list.files) && list.files.some(f => f.id === fileId), r.text.slice(0, 200));
93
+ check('列表文件含 tags 数组', list.files.some(f => Array.isArray(f.tags)), '');
94
+ check('列表含 pagination', !!list.pagination, '');
95
+
96
+ // 7. 文件详情
97
+ r = await req('GET', `/api/files/${fileId}`, { headers: auth });
98
+ check(`GET /api/files/${fileId} → 200`, r.status === 200, `status=${r.status}`);
99
+
100
+ // 8. 内容
101
+ r = await req('GET', `/api/files/${fileId}/content`, { headers: auth });
102
+ check(`GET /api/files/${fileId}/content → 200`, r.status === 200, `status=${r.status}`);
103
+
104
+ // 9. 渲染
105
+ r = await req('GET', `/api/files/${fileId}/render`, { headers: auth });
106
+ check(`render → 200`, r.status === 200, `status=${r.status}`);
107
+ check('render 返回 html 且含高亮/katex', r.text.includes('<code') || r.text.includes('katex'), r.text.slice(0, 120));
108
+
109
+ // 10. 搜索(FTS)
110
+ await new Promise(res => setTimeout(res, 400)); // 等 FTS 异步索引
111
+ r = await req('GET', `/api/files/search?q=${encodeURIComponent('jpage_unique_token_alpha')}`, { headers: auth });
112
+ check('FTS 搜索 → 200', r.status === 200, `status=${r.status}`);
113
+ let search = {};
114
+ try { search = JSON.parse(r.text); } catch {}
115
+ check('FTS 搜索命中目标文件', Array.isArray(search.files) && search.files.some(f => f.id === fileId), r.text.slice(0, 200));
116
+
117
+ // 11. 覆盖上传(版本)
118
+ r = await req('POST', `/api/files/${fileId}/overwrite-json`, { headers: auth, body: { content: '# v2\n\n更新内容' } });
119
+ check('overwrite-json → 200', r.status === 200, `status=${r.status} ${r.text}`);
120
+ r = await req('GET', `/api/files/${fileId}/versions`, { headers: auth });
121
+ check('版本列表 → 200', r.status === 200, `status=${r.status}`);
122
+
123
+ // 12. 短链 /s/:key
124
+ r = await req('GET', `/s/${shareKey}`);
125
+ check(`短链 /s/${shareKey} → 200`, r.status === 200, `status=${r.status}`);
126
+ check('短链渲染含 html', r.text.includes('<html') || r.text.includes('<!DOCTYPE'), r.text.slice(0, 80));
127
+
128
+ // 13. 标签 / 分类(含分类名称缓存验证)
129
+ r = await req('POST', '/api/tags', { headers: auth, body: { name: 'perf-tag' } });
130
+ check('创建标签 → 200/201', r.status === 200 || r.status === 201, `status=${r.status}`);
131
+ r = await req('PUT', `/api/files/${fileId}/tags`, { headers: auth, body: { tagIds: [1] } });
132
+ check('给文件打标签 → 200', r.status === 200, `status=${r.status}`);
133
+ r = await req('POST', '/api/categories', { headers: auth, body: { name: '缓存验证分类' } });
134
+ check('创建分类 → 200', r.status === 200, `status=${r.status}`);
135
+ let createdCat = {};
136
+ try { createdCat = JSON.parse(r.text); } catch {}
137
+ if (createdCat.id) {
138
+ r = await req('PUT', `/api/files/${fileId}/category`, { headers: auth, body: { categoryId: createdCat.id } });
139
+ check('给文件设置分类 → 200', r.status === 200, `status=${r.status}`);
140
+ // 列表应通过分类缓存返回正确的 category_name
141
+ r = await req('GET', '/api/files?keyword=perf-test.md', { headers: auth });
142
+ let withCat = {};
143
+ try { withCat = JSON.parse(r.text); } catch {}
144
+ const target = (withCat.files || []).find(f => f.id === fileId);
145
+ check('列表 category_name 由缓存正确填充', !!target && target.category_name === '缓存验证分类',
146
+ target ? `got category_name="${target.category_name}"` : 'file not found in list');
147
+ // 重命名后缓存应失效,列表反映新名
148
+ r = await req('PUT', `/api/categories/${createdCat.id}`, { headers: auth, body: { name: '重命名后的分类' } });
149
+ check('重命名分类 → 200', r.status === 200, `status=${r.status}`);
150
+ r = await req('GET', '/api/files?keyword=perf-test.md', { headers: auth });
151
+ let renamed = {};
152
+ try { renamed = JSON.parse(r.text); } catch {}
153
+ const target2 = (renamed.files || []).find(f => f.id === fileId);
154
+ check('分类重命名后列表反映新名称(缓存失效)', !!target2 && target2.category_name === '重命名后的分类',
155
+ target2 ? `got category_name="${target2.category_name}"` : 'file not found');
156
+ }
157
+ r = await req('GET', '/api/categories', { headers: auth });
158
+ check('GET /api/categories → 200', r.status === 200, `status=${r.status}`);
159
+
160
+ // 14. 静态资源缓存头(优化后应为长缓存)
161
+ r = await req('GET', '/css/style.css?v=1.5.0');
162
+ check('GET /css/style.css → 200', r.status === 200, `status=${r.status}`);
163
+ const cssCache = r.headers['cache-control'] || '';
164
+ console.log(` (css cache-control: "${cssCache}")`);
165
+
166
+ // 15. 删除文件
167
+ r = await req('DELETE', `/api/files/${fileId}`, { headers: auth });
168
+ check('删除文件 → 200', r.status === 200, `status=${r.status}`);
169
+
170
+ // 16. 二次渲染(缓存路径,若启用)
171
+ // 重新上传一个用于短链热路径
172
+ r = await req('POST', '/api/files/upload-json', { headers: auth, body: { name: 'cache-target.md', content: '# C\n\n缓存验证', isPublic: true } });
173
+ let c2 = {};
174
+ try { c2 = JSON.parse(r.text); } catch {}
175
+ if (c2.id) {
176
+ const t1 = process.hrtime.bigint();
177
+ r = await req('GET', `/api/files/${c2.id}/render`, { headers: auth });
178
+ const t2 = process.hrtime.bigint();
179
+ const ms1 = Number(t2 - t1) / 1e6;
180
+ const t3 = process.hrtime.bigint();
181
+ r = await req('GET', `/api/files/${c2.id}/render`, { headers: auth });
182
+ const t4 = process.hrtime.bigint();
183
+ const ms2 = Number(t4 - t3) / 1e6;
184
+ console.log(` (render cold=${ms1.toFixed(2)}ms, warm=${ms2.toFixed(2)}ms)`);
185
+ check('二次渲染同样成功', r.status === 200, `status=${r.status}`);
186
+ await req('DELETE', `/api/files/${c2.id}`, { headers: auth });
187
+ }
188
+
189
+ // 17. 不存在的资源 id → 404(非 500)
190
+ r = await req('GET', '/api/files/99999/render', { headers: auth });
191
+ check('render 不存在文件 → 404', r.status === 404, `status=${r.status}`);
192
+
193
+ console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`);
194
+ if (fail > 0) { console.log('失败项:'); failures.forEach(f => console.log(' - ' + f)); process.exit(1); }
195
+ process.exit(0);
196
+ }
197
+
198
+ run().catch(e => { console.error('套件异常:', e); process.exit(2); });
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ # 启动一个隔离的 jpage 实例用于测试
3
+ # 用法: PORT=8891 bash test/run-server.sh & (后台)
4
+ # bash test/run-server.sh (前台)
5
+ set -euo pipefail
6
+ PORT="${PORT:-8890}"
7
+ DATA_DIR="${JPAGE_DATA_DIR:-$(pwd)/data-test-$PORT}"
8
+ export PORT NODE_ENV ADMIN_USER ADMIN_PASSWORD JPAGE_DATA_DIR MCP_TOKEN
9
+ NODE_ENV="${NODE_ENV:-development}"
10
+ ADMIN_USER="${ADMIN_USER:-admin}"
11
+ ADMIN_PASSWORD="${ADMIN_PASSWORD:-testpassword123}"
12
+ JPAGE_DATA_DIR="$DATA_DIR"
13
+ MCP_TOKEN="${MCP_TOKEN:-test-mcp-token-abc}"
14
+ mkdir -p "$DATA_DIR"
15
+ exec node server.js
@@ -0,0 +1,88 @@
1
+ // bin/args.js argv 解析器单元测试
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const { parse } = require('../../bin/args');
5
+
6
+ test('parse: 空数组 → cmd/sub 为 null', () => {
7
+ const r = parse([]);
8
+ assert.strictEqual(r.cmd, null);
9
+ assert.strictEqual(r.sub, null);
10
+ assert.deepStrictEqual(r.opts, {});
11
+ assert.deepStrictEqual(r.positional, []);
12
+ });
13
+
14
+ test('parse: 单命令', () => {
15
+ const r = parse(['ls']);
16
+ assert.strictEqual(r.cmd, 'ls');
17
+ assert.strictEqual(r.sub, null);
18
+ assert.deepStrictEqual(r.positional, ['ls']);
19
+ });
20
+
21
+ test('parse: 命令 + 子命令', () => {
22
+ const r = parse(['skills', 'ls']);
23
+ assert.strictEqual(r.cmd, 'skills');
24
+ assert.strictEqual(r.sub, 'ls');
25
+ assert.deepStrictEqual(r.positional, ['skills', 'ls']);
26
+ });
27
+
28
+ test('parse: 命令 + 位置参数(3 个)', () => {
29
+ const r = parse(['mv', '12', 'new.html']);
30
+ assert.strictEqual(r.cmd, 'mv');
31
+ assert.strictEqual(r.sub, '12');
32
+ assert.deepStrictEqual(r.positional, ['mv', '12', 'new.html']);
33
+ });
34
+
35
+ test('parse: --key value 形式', () => {
36
+ const r = parse(['ls', '--page', '2', '--limit', '50']);
37
+ assert.strictEqual(r.opts.page, '2');
38
+ assert.strictEqual(r.opts.limit, '50');
39
+ });
40
+
41
+ test('parse: --key=value 形式', () => {
42
+ const r = parse(['ls', '--page=2', '--limit=50']);
43
+ assert.strictEqual(r.opts.page, '2');
44
+ assert.strictEqual(r.opts.limit, '50');
45
+ });
46
+
47
+ test('parse: 布尔标志(无值)', () => {
48
+ const r = parse(['upload', 'a.html', '--public']);
49
+ assert.strictEqual(r.opts.public, true);
50
+ assert.deepStrictEqual(r.positional, ['upload', 'a.html']);
51
+ });
52
+
53
+ test('parse: 布尔标志后跟另一个选项时识别为布尔', () => {
54
+ const r = parse(['upload', 'a.html', '--public', '--token', 'x']);
55
+ assert.strictEqual(r.opts.public, true);
56
+ assert.strictEqual(r.opts.token, 'x');
57
+ });
58
+
59
+ test('parse: -- 后内容全部当位置参数', () => {
60
+ const r = parse(['cat', '--', '--weird-id']);
61
+ assert.deepStrictEqual(r.positional, ['cat', '--weird-id']);
62
+ assert.strictEqual(r.opts['weird-id'], undefined);
63
+ });
64
+
65
+ test('parse: 选项在命令之前也能解析', () => {
66
+ const r = parse(['--token', 'abc', 'ls', '--page', '1']);
67
+ assert.strictEqual(r.opts.token, 'abc');
68
+ assert.strictEqual(r.cmd, 'ls');
69
+ assert.strictEqual(r.opts.page, '1');
70
+ });
71
+
72
+ test('parse: --token=value 与位置参数混合', () => {
73
+ const r = parse(['--token=abc', 'upload', 'x.html']);
74
+ assert.strictEqual(r.opts.token, 'abc');
75
+ assert.strictEqual(r.cmd, 'upload');
76
+ assert.strictEqual(r.sub, 'x.html');
77
+ });
78
+
79
+ test('parse: 末尾布尔标志(无后续 token)', () => {
80
+ const r = parse(['upload', 'x.html', '--public']);
81
+ assert.strictEqual(r.opts.public, true);
82
+ });
83
+
84
+ test('parse: 多个布尔标志', () => {
85
+ const r = parse(['x', '--public', '--verbose']);
86
+ assert.strictEqual(r.opts.public, true);
87
+ assert.strictEqual(r.opts.verbose, true);
88
+ });
@@ -0,0 +1,89 @@
1
+ // bin/config.js 配置解析单元测试
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { resolveConfig, parseEnvFile, loadEnvUp, DEFAULT_BASE } = require('../../bin/config');
8
+
9
+ test('parseEnvFile: 基本 KEY=VALUE', () => {
10
+ const tmp = path.join(os.tmpdir(), '.env-test-' + process.pid);
11
+ fs.writeFileSync(tmp, 'MCP_TOKEN=abc123\nJPAGE_BASE=http://1.2.3.4:8858\n');
12
+ const parsed = parseEnvFile(tmp);
13
+ assert.strictEqual(parsed.MCP_TOKEN, 'abc123');
14
+ assert.strictEqual(parsed.JPAGE_BASE, 'http://1.2.3.4:8858');
15
+ fs.unlinkSync(tmp);
16
+ });
17
+
18
+ test('parseEnvFile: 去引号 + 跳过注释/空行', () => {
19
+ const tmp = path.join(os.tmpdir(), '.env-test2-' + process.pid);
20
+ fs.writeFileSync(tmp, '# 注释\nFOO="bar baz"\nQUOTED=\'single\'\n\n');
21
+ const parsed = parseEnvFile(tmp);
22
+ assert.strictEqual(parsed.FOO, 'bar baz');
23
+ assert.strictEqual(parsed.QUOTED, 'single');
24
+ fs.unlinkSync(tmp);
25
+ });
26
+
27
+ test('parseEnvFile: 不存在返回空对象', () => {
28
+ assert.deepStrictEqual(parseEnvFile('/no/such/path/.env'), {});
29
+ });
30
+
31
+ test('resolveConfig: --token 最高优先级', () => {
32
+ const r = resolveConfig({ token: 'cli' }, { JPAGE_TOKEN: 'env', MCP_TOKEN: 'global' });
33
+ assert.strictEqual(r.token, 'cli');
34
+ });
35
+
36
+ test('resolveConfig: JPAGE_TOKEN 高于 MCP_TOKEN', () => {
37
+ const r = resolveConfig({}, { JPAGE_TOKEN: 'jp', MCP_TOKEN: 'mc' });
38
+ assert.strictEqual(r.token, 'jp');
39
+ });
40
+
41
+ test('resolveConfig: 回退 MCP_TOKEN', () => {
42
+ const r = resolveConfig({}, { MCP_TOKEN: 'mc' });
43
+ assert.strictEqual(r.token, 'mc');
44
+ });
45
+
46
+ test('resolveConfig: 无 token 返回 null', () => {
47
+ // 用临时 cwd,避免读到项目根的 .env(其中有 MCP_TOKEN)造成泄漏
48
+ const cleanCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-clean-'));
49
+ try {
50
+ const r = resolveConfig({}, {}, cleanCwd);
51
+ assert.strictEqual(r.token, null);
52
+ } finally {
53
+ fs.rmSync(cleanCwd, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test('resolveConfig: --base 优先于环境变量', () => {
58
+ const r = resolveConfig({ base: 'http://cli:9' }, { JPAGE_BASE: 'http://env:9' });
59
+ assert.strictEqual(r.base, 'http://cli:9');
60
+ });
61
+
62
+ test('resolveConfig: base 去尾部斜杠', () => {
63
+ const r = resolveConfig({ base: 'http://x:9///' }, {});
64
+ assert.strictEqual(r.base, 'http://x:9');
65
+ });
66
+
67
+ test('resolveConfig: 默认 base', () => {
68
+ assert.strictEqual(resolveConfig({}, {}).base, DEFAULT_BASE);
69
+ });
70
+
71
+ test('loadEnvUp: 向上查找 .env 并合并', () => {
72
+ // 在临时目录树里放 .env,验证向上查找
73
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-env-'));
74
+ const sub = path.join(root, 'a', 'b');
75
+ fs.mkdirSync(sub, { recursive: true });
76
+ fs.writeFileSync(path.join(root, '.env'), 'MCP_TOKEN=root-token\nJPAGE_BASE=http://root:9\n');
77
+ const loaded = loadEnvUp(sub);
78
+ assert.strictEqual(loaded.MCP_TOKEN, 'root-token');
79
+ assert.strictEqual(loaded.JPAGE_BASE, 'http://root:9');
80
+ fs.rmSync(root, { recursive: true, force: true });
81
+ });
82
+
83
+ test('resolveConfig: .env 里的 MCP_TOKEN 被用作回退', () => {
84
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jpage-env2-'));
85
+ fs.writeFileSync(path.join(root, '.env'), 'MCP_TOKEN=from-file\n');
86
+ const r = resolveConfig({}, {}, root);
87
+ assert.strictEqual(r.token, 'from-file');
88
+ fs.rmSync(root, { recursive: true, force: true });
89
+ });
@@ -0,0 +1,100 @@
1
+ // lib/crypto.js 单元测试:AES-256-GCM 加解密往返、密钥持久化、密钥变更后解密失败。
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+
8
+ // 用临时数据目录隔离密钥文件
9
+ const TMP_DATA = path.join(__dirname, '..', `data-crypto-${process.pid}-${Date.now()}`);
10
+
11
+ function freshCrypto() {
12
+ // 清除缓存,让 lib/crypto.js 重新读取 JPAGE_DATA_DIR / 环境变量
13
+ delete require.cache[require.resolve('../../lib/paths')];
14
+ delete require.cache[require.resolve('../../lib/crypto')];
15
+ return require('../../lib/crypto');
16
+ }
17
+
18
+ test.beforeEach(() => {
19
+ fs.rmSync(TMP_DATA, { recursive: true, force: true });
20
+ fs.mkdirSync(TMP_DATA, { recursive: true });
21
+ delete process.env.TOKEN_ENCRYPTION_KEY;
22
+ process.env.JPAGE_DATA_DIR = TMP_DATA;
23
+ });
24
+
25
+ test.afterEach(() => {
26
+ fs.rmSync(TMP_DATA, { recursive: true, force: true });
27
+ delete process.env.TOKEN_ENCRYPTION_KEY;
28
+ });
29
+
30
+ test('加密后解密能还原原文', () => {
31
+ const { encryptToken, decryptToken } = freshCrypto();
32
+ const plain = 'jp_abcdefghijklmnopqrstuvwxyz123456';
33
+ const enc = encryptToken(plain);
34
+ assert.notStrictEqual(enc, plain, '密文不应等于明文');
35
+ assert.strictEqual(decryptToken(enc), plain);
36
+ });
37
+
38
+ test('同一明文每次加密结果不同(随机 IV)', () => {
39
+ const { encryptToken } = freshCrypto();
40
+ const plain = 'jp_sametokenvalue';
41
+ const a = encryptToken(plain);
42
+ const b = encryptToken(plain);
43
+ assert.notStrictEqual(a, b, '不同次加密应产生不同密文');
44
+ });
45
+
46
+ test('密文格式为 iv : ciphertext : authTag 三段 base64', () => {
47
+ const { encryptToken } = freshCrypto();
48
+ const enc = encryptToken('jp_test');
49
+ const parts = enc.split(':');
50
+ assert.strictEqual(parts.length, 3);
51
+ // 每段都是合法 base64
52
+ parts.forEach(p => assert.ok(Buffer.from(p, 'base64').length > 0));
53
+ });
54
+
55
+ test('密钥文件自动生成在数据目录并持久化', () => {
56
+ const { encryptToken } = freshCrypto();
57
+ const enc = encryptToken('jp_persist');
58
+ const keyFile = path.join(TMP_DATA, 'token-key.key');
59
+ assert.ok(fs.existsSync(keyFile), '应生成 token-key.key 文件');
60
+ const hex = fs.readFileSync(keyFile, 'utf8').trim();
61
+ assert.match(hex, /^[0-9a-fA-F]{64}$/, '密钥文件为 32 字节 hex');
62
+
63
+ // 再次 require(重新读文件)应能解密旧密文
64
+ const { decryptToken: decryptAgain } = freshCrypto();
65
+ assert.strictEqual(decryptAgain(enc), 'jp_persist');
66
+ });
67
+
68
+ test('密文被篡改 → 解密抛错(GCM 完整性校验)', () => {
69
+ const { encryptToken, decryptToken } = freshCrypto();
70
+ const enc = encryptToken('jp_tamper');
71
+ const parts = enc.split(':');
72
+ // 篡改 ciphertext 段的第一个字符
73
+ const tamperedData = Buffer.from(parts[1], 'base64');
74
+ tamperedData[0] ^= 0xff;
75
+ const tampered = [parts[0], tamperedData.toString('base64'), parts[2]].join(':');
76
+ assert.throws(() => decryptToken(tampered), /unsupported|auth|decrypt|final/i);
77
+ });
78
+
79
+ test('格式错误的密文 → 解密抛错', () => {
80
+ const { decryptToken } = freshCrypto();
81
+ assert.throws(() => decryptToken('not-a-valid-ciphertext'));
82
+ assert.throws(() => decryptToken('a:b'));
83
+ });
84
+
85
+ test('环境变量 TOKEN_ENCRYPTION_KEY 优先于密钥文件(合法 hex)', () => {
86
+ process.env.TOKEN_ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
87
+ const { encryptToken, decryptToken } = freshCrypto();
88
+ const enc = encryptToken('jp_env_key');
89
+ assert.strictEqual(decryptToken(enc), 'jp_env_key');
90
+ assert.ok(!fs.existsSync(path.join(TMP_DATA, 'token-key.key')), '用环境变量时不应生成密钥文件');
91
+ });
92
+
93
+ test('reloadKey() 切换密钥后旧密文无法解密', () => {
94
+ const mod = freshCrypto();
95
+ const enc = mod.encryptToken('jp_rotate');
96
+ // 换一个新环境变量密钥并 reload
97
+ process.env.TOKEN_ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
98
+ mod.reloadKey();
99
+ assert.throws(() => mod.decryptToken(enc), '换密钥后旧密文应无法解密');
100
+ });
@@ -0,0 +1,52 @@
1
+ // lib/fts.js 单元测试
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const path = require('path');
5
+ const { escapeFtsQuery, isFtsIndexable } = require('../../lib/fts');
6
+
7
+ test('isFtsIndexable:可索引扩展名', () => {
8
+ assert.ok(isFtsIndexable('html', 'a.html'));
9
+ assert.ok(isFtsIndexable('markdown', 'b.md'));
10
+ assert.ok(isFtsIndexable('html', 'c.MD')); // 大写
11
+ assert.ok(isFtsIndexable('markdown', 'd.markdown'));
12
+ assert.ok(isFtsIndexable('text', 'e.txt'));
13
+ assert.ok(isFtsIndexable('html', 'f.HTM'));
14
+ });
15
+
16
+ test('isFtsIndexable:不可索引', () => {
17
+ assert.ok(!isFtsIndexable('bundle', 'a.html')); // bundle 永不索引
18
+ assert.ok(!isFtsIndexable('html', 'a.png'));
19
+ assert.ok(!isFtsIndexable('html', 'noext'));
20
+ assert.ok(!isFtsIndexable('html', ''));
21
+ });
22
+
23
+ test('escapeFtsQuery:移除 FTS5 特殊字符(特殊字符替换为空格后分词)', () => {
24
+ const r = escapeFtsQuery('hello"world*test');
25
+ // " 和 * 是 FTS5 特殊字符,替换为空格后分成三个 token
26
+ assert.strictEqual(r, '"hello" "world" "test"');
27
+ });
28
+
29
+ test('escapeFtsQuery:空查询返回空串', () => {
30
+ assert.strictEqual(escapeFtsQuery(''), '');
31
+ assert.strictEqual(escapeFtsQuery(' '), '');
32
+ assert.strictEqual(escapeFtsQuery('"*()'), '');
33
+ });
34
+
35
+ test('escapeFtsQuery:每个 token 被双引号包裹', () => {
36
+ const r = escapeFtsQuery('hello world');
37
+ assert.strictEqual(r, '"hello" "world"');
38
+ });
39
+
40
+ test('escapeFtsQuery:CJK 逐字分词', () => {
41
+ const r = escapeFtsQuery('测试');
42
+ // 每个 CJK 字符前后加空格,最终每个字单独成 token
43
+ assert.ok(r.includes('"测"'));
44
+ assert.ok(r.includes('"试"'));
45
+ });
46
+
47
+ test('escapeFtsQuery:CJK + ASCII 混合', () => {
48
+ const r = escapeFtsQuery('api 测试');
49
+ assert.ok(r.includes('"api"'));
50
+ assert.ok(r.includes('"测"'));
51
+ assert.ok(r.includes('"试"'));
52
+ });
@@ -0,0 +1,76 @@
1
+ // lib/render-cache.js 单元测试
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const {
5
+ renderCacheKey,
6
+ getRenderedHtml,
7
+ setRenderedHtml,
8
+ invalidateRenderCache,
9
+ clearRenderCache,
10
+ } = require('../../lib/render-cache');
11
+
12
+ function fakeFile(id, overrides = {}) {
13
+ return {
14
+ id,
15
+ stored_name: `f${id}.md`,
16
+ updated_at: '2026-01-01 00:00:00',
17
+ is_bundle: 0,
18
+ entry_path: null,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ test('renderCacheKey:含 id/stored_name/updated_at/is_bundle/entry_path', () => {
24
+ const key = renderCacheKey(fakeFile(1));
25
+ assert.ok(key.startsWith('1:f1.md:2026-01-01 00:00:00:0:'));
26
+ });
27
+
28
+ test('renderCacheKey:stored_name 不同则 key 不同(历史版本场景)', () => {
29
+ const a = renderCacheKey(fakeFile(1, { stored_name: 'current.md' }));
30
+ const b = renderCacheKey(fakeFile(1, { stored_name: 'v2.md' }));
31
+ assert.notStrictEqual(a, b);
32
+ });
33
+
34
+ test('getRenderedHtml:未缓存返回 null', () => {
35
+ clearRenderCache();
36
+ assert.strictEqual(getRenderedHtml(fakeFile(99)), null);
37
+ });
38
+
39
+ test('setRenderedHtml / getRenderedHtml:写入后命中', () => {
40
+ clearRenderCache();
41
+ const f = fakeFile(2);
42
+ setRenderedHtml(f, '<html></html>');
43
+ assert.strictEqual(getRenderedHtml(f), '<html></html>');
44
+ });
45
+
46
+ test('setRenderedHtml:updated_at 变化后旧缓存失效', () => {
47
+ clearRenderCache();
48
+ const f1 = fakeFile(3, { updated_at: '2026-01-01 00:00:00' });
49
+ setRenderedHtml(f1, 'old');
50
+ const f2 = fakeFile(3, { updated_at: '2026-01-02 00:00:00' });
51
+ assert.strictEqual(getRenderedHtml(f2), null);
52
+ });
53
+
54
+ test('invalidateRenderCache:按 fileId 清除该文件全部缓存', () => {
55
+ clearRenderCache();
56
+ setRenderedHtml(fakeFile(5, { stored_name: 'a.md' }), 'a');
57
+ setRenderedHtml(fakeFile(5, { stored_name: 'b.md' }), 'b');
58
+ setRenderedHtml(fakeFile(6), 'other');
59
+ invalidateRenderCache(5);
60
+ assert.strictEqual(getRenderedHtml(fakeFile(5, { stored_name: 'a.md' })), null);
61
+ assert.strictEqual(getRenderedHtml(fakeFile(5, { stored_name: 'b.md' })), null);
62
+ assert.ok(getRenderedHtml(fakeFile(6))); // 其它文件不受影响
63
+ });
64
+
65
+ test('LRU 淘汰:超过 256 条时淘汰最早的', () => {
66
+ clearRenderCache();
67
+ // 填满 256 条
68
+ for (let i = 0; i < 256; i++) {
69
+ setRenderedHtml(fakeFile(1000 + i), `html${i}`);
70
+ }
71
+ // 第 257 条触发淘汰 id=1000 的
72
+ setRenderedHtml(fakeFile(2000), 'new');
73
+ assert.strictEqual(getRenderedHtml(fakeFile(1000)), null);
74
+ assert.strictEqual(getRenderedHtml(fakeFile(2000)), 'new');
75
+ clearRenderCache();
76
+ });