@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.
- package/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// 文件路由安全关键路径集成测试:ZIP 上传(安全校验)/ CSP 分级下发 / 权限隔离。
|
|
2
|
+
// 与 files.test.js 共用同一套 helper(隔离 SQLite 数据目录 + admin agent)。
|
|
3
|
+
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const assert = require('node:assert');
|
|
6
|
+
const request = require('supertest');
|
|
7
|
+
const JSZip = require('jszip');
|
|
8
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
9
|
+
|
|
10
|
+
let env;
|
|
11
|
+
let admin; // admin agent
|
|
12
|
+
let user; // 普通用户 agent
|
|
13
|
+
let otherUser; // 另一个普通用户 agent
|
|
14
|
+
|
|
15
|
+
// 用 JSZip 构造 ZIP buffer(同步拼装,上传时转 base64)
|
|
16
|
+
async function makeZip(files) {
|
|
17
|
+
const zip = new JSZip();
|
|
18
|
+
for (const [name, content] of Object.entries(files)) {
|
|
19
|
+
zip.file(name, content);
|
|
20
|
+
}
|
|
21
|
+
return zip.generateAsync({ type: 'nodebuffer' });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test.before(async () => {
|
|
25
|
+
env = createTestEnv();
|
|
26
|
+
await env.ready();
|
|
27
|
+
|
|
28
|
+
admin = request.agent(env.app);
|
|
29
|
+
await admin.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
30
|
+
|
|
31
|
+
// admin 创建两个普通用户
|
|
32
|
+
await admin.post('/api/users').send({ username: 'alice', password: 'alicepass123', role: 'user' });
|
|
33
|
+
await admin.post('/api/users').send({ username: 'bob', password: 'bobpass123', role: 'user' });
|
|
34
|
+
|
|
35
|
+
user = request.agent(env.app);
|
|
36
|
+
await user.post('/api/auth/login').send({ username: 'alice', password: 'alicepass123' });
|
|
37
|
+
otherUser = request.agent(env.app);
|
|
38
|
+
await otherUser.post('/api/auth/login').send({ username: 'bob', password: 'bobpass123' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test.after(() => {
|
|
42
|
+
env.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ====================== ZIP 上传安全 ======================
|
|
46
|
+
|
|
47
|
+
test('ZIP bundle 上传:index.html + 子目录资源 → is_bundle=1', async () => {
|
|
48
|
+
const buf = await makeZip({
|
|
49
|
+
'index.html': '<!DOCTYPE html><html><body><h1>bundle</h1></body></html>',
|
|
50
|
+
'css/style.css': 'body{color:red}',
|
|
51
|
+
'img/logo.svg': '<svg></svg>',
|
|
52
|
+
});
|
|
53
|
+
const res = await user.post('/api/files/upload-zip-base64').send({
|
|
54
|
+
name: 'site.zip',
|
|
55
|
+
content: buf.toString('base64'),
|
|
56
|
+
isPublic: false,
|
|
57
|
+
});
|
|
58
|
+
assert.strictEqual(res.status, 200);
|
|
59
|
+
assert.strictEqual(res.body.is_bundle, 1);
|
|
60
|
+
assert.ok(res.body.entry_path);
|
|
61
|
+
assert.strictEqual(res.body.file_type, 'html');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('ZIP batch 上传:多个根目录 HTML → type=batch', async () => {
|
|
65
|
+
const buf = await makeZip({
|
|
66
|
+
'a.html': '<p>a</p>',
|
|
67
|
+
'b.html': '<p>b</p>',
|
|
68
|
+
});
|
|
69
|
+
const res = await user.post('/api/files/upload-zip-base64').send({
|
|
70
|
+
name: 'batch.zip',
|
|
71
|
+
content: buf.toString('base64'),
|
|
72
|
+
isPublic: false,
|
|
73
|
+
});
|
|
74
|
+
assert.strictEqual(res.status, 200);
|
|
75
|
+
assert.strictEqual(res.body.type, 'batch');
|
|
76
|
+
assert.ok(res.body.count >= 2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('ZIP 穿越防护:上传带 ../ 的 ZIP 不会写出越界文件', async () => {
|
|
80
|
+
// JSZip 在生成阶段会把真实 ../ 规范化掉,所以这里无法通过 HTTP 端点
|
|
81
|
+
// 触发 validateZipEntries 的目录穿越分支(该分支由 test/unit/zip.test.js
|
|
82
|
+
// 直接注入恶意 normalizedPath 覆盖)。本用例验证端到端的最终保证:
|
|
83
|
+
// 即便 zip 里有 ../ 前缀条目,服务端也不会在 UPLOAD_DIR 之外创建文件。
|
|
84
|
+
const zip = new JSZip();
|
|
85
|
+
zip.file('../escape-attempt.txt', 'evil'); // 生成后会被规范化为 escape-attempt.txt
|
|
86
|
+
zip.file('index.html', '<p>ok</p>');
|
|
87
|
+
const buf = await zip.generateAsync({ type: 'nodebuffer' });
|
|
88
|
+
const res = await user.post('/api/files/upload-zip-base64').send({
|
|
89
|
+
name: 'traversal.zip',
|
|
90
|
+
content: buf.toString('base64'),
|
|
91
|
+
});
|
|
92
|
+
// 上传成功(被规范化为合法 bundle),但越界文件绝不能存在
|
|
93
|
+
assert.strictEqual(res.status, 200);
|
|
94
|
+
const dataDir = env.dataDir;
|
|
95
|
+
const uploadsDir = require('path').join(dataDir, 'uploads');
|
|
96
|
+
const walk = (dir) => {
|
|
97
|
+
const found = [];
|
|
98
|
+
for (const e of require('fs').readdirSync(dir, { withFileTypes: true })) {
|
|
99
|
+
const full = require('path').join(dir, e.name);
|
|
100
|
+
if (e.isDirectory()) found.push(...walk(full));
|
|
101
|
+
else found.push(full);
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
};
|
|
105
|
+
const allFiles = walk(uploadsDir);
|
|
106
|
+
// UPLOAD_DIR 之外不应出现 escape-attempt.txt
|
|
107
|
+
const escaped = allFiles.filter(f => !f.startsWith(uploadsDir));
|
|
108
|
+
assert.strictEqual(escaped.length, 0, '不应有文件写到 UPLOAD_DIR 之外');
|
|
109
|
+
// 也不应有真正叫 escape-attempt.txt 的越界文件(规范化后落在 bundle 目录内才算正常)
|
|
110
|
+
assert.ok(!allFiles.some(f => f.endsWith('../escape-attempt.txt')));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('ZIP 无任何 HTML/Markdown → 400', async () => {
|
|
114
|
+
const buf = await makeZip({
|
|
115
|
+
'a.css': 'body{}',
|
|
116
|
+
'b.js': 'console.log(1)',
|
|
117
|
+
});
|
|
118
|
+
const res = await user.post('/api/files/upload-zip-base64').send({
|
|
119
|
+
name: 'nohtml.zip',
|
|
120
|
+
content: buf.toString('base64'),
|
|
121
|
+
});
|
|
122
|
+
assert.strictEqual(res.status, 400);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('upload-zip-base64 非 zip 扩展名 → 400', async () => {
|
|
126
|
+
const res = await user.post('/api/files/upload-zip-base64').send({
|
|
127
|
+
name: 'notzip.txt',
|
|
128
|
+
content: 'aGVsbG8=',
|
|
129
|
+
});
|
|
130
|
+
assert.strictEqual(res.status, 400);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ====================== CSP 分级下发 ======================
|
|
134
|
+
|
|
135
|
+
test('Markdown 渲染页下发严格 CSP(含 nonce,无 unsafe-inline script)', async () => {
|
|
136
|
+
const up = await admin.post('/api/files/upload-json').send({
|
|
137
|
+
name: 'csp-md.md',
|
|
138
|
+
content: '# 标题\n\n```mermaid\ngraph LR;A-->B\n```',
|
|
139
|
+
isPublic: true,
|
|
140
|
+
});
|
|
141
|
+
const res = await admin.get(`/api/files/${up.body.id}/render`);
|
|
142
|
+
assert.strictEqual(res.status, 200);
|
|
143
|
+
const csp = res.headers['content-security-policy'] || '';
|
|
144
|
+
// 必须有 script-src 且含 nonce(不能是 unsafe-inline)
|
|
145
|
+
assert.match(csp, /script-src[^;]*'nonce-/);
|
|
146
|
+
assert.ok(!/script-src[^;]*'unsafe-inline'/.test(csp), 'Markdown 页不应放开 script unsafe-inline');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('HTML 渲染页下发宽松 CSP(允许 inline script + https)', async () => {
|
|
150
|
+
const up = await admin.post('/api/files/upload-json').send({
|
|
151
|
+
name: 'csp-html.html',
|
|
152
|
+
content: '<!DOCTYPE html><html><body><script>console.log(1)</script></body></html>',
|
|
153
|
+
isPublic: true,
|
|
154
|
+
});
|
|
155
|
+
const res = await admin.get(`/api/files/${up.body.id}/render`);
|
|
156
|
+
assert.strictEqual(res.status, 200);
|
|
157
|
+
const csp = res.headers['content-security-policy'] || '';
|
|
158
|
+
assert.match(csp, /script-src[^;]*'unsafe-inline'/);
|
|
159
|
+
assert.match(csp, /script-src[^;]*https:/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('管理界面 API 下发严格 CSP(无 unsafe-inline script)', async () => {
|
|
163
|
+
// files 列表走 APP_CSP(非渲染端点)
|
|
164
|
+
const res = await admin.get('/api/files');
|
|
165
|
+
assert.strictEqual(res.status, 200);
|
|
166
|
+
const csp = res.headers['content-security-policy'] || '';
|
|
167
|
+
assert.match(csp, /script-src[^;]*'self'/);
|
|
168
|
+
assert.ok(!/script-src[^;]*'unsafe-inline'/.test(csp), '管理界面不应放开 script unsafe-inline');
|
|
169
|
+
// 管理界面 frame-ancestors none
|
|
170
|
+
assert.match(csp, /frame-ancestors 'none'/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ====================== 权限隔离 ======================
|
|
174
|
+
|
|
175
|
+
test('普通用户上传的私有文件,其他普通用户访问 → 403', async () => {
|
|
176
|
+
// alice 上传私有文件
|
|
177
|
+
const up = await user.post('/api/files/upload-json').send({
|
|
178
|
+
name: 'alice-private.md',
|
|
179
|
+
content: '# secret',
|
|
180
|
+
isPublic: false,
|
|
181
|
+
});
|
|
182
|
+
assert.strictEqual(up.body.is_public, 0);
|
|
183
|
+
// bob 尝试读取详情 → 403
|
|
184
|
+
const detail = await otherUser.get(`/api/files/${up.body.id}`);
|
|
185
|
+
assert.strictEqual(detail.status, 403);
|
|
186
|
+
// bob 尝试读取原文 → 403
|
|
187
|
+
const content = await otherUser.get(`/api/files/${up.body.id}/content`);
|
|
188
|
+
assert.strictEqual(content.status, 403);
|
|
189
|
+
// bob 尝试渲染 → 403
|
|
190
|
+
const render = await otherUser.get(`/api/files/${up.body.id}/render`);
|
|
191
|
+
assert.strictEqual(render.status, 403);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('普通用户不能修改/删除他人私有文件 → 403', async () => {
|
|
195
|
+
const up = await user.post('/api/files/upload-json').send({
|
|
196
|
+
name: 'alice-no-edit.md',
|
|
197
|
+
content: 'x',
|
|
198
|
+
isPublic: false,
|
|
199
|
+
});
|
|
200
|
+
// bob 尝试改名
|
|
201
|
+
const rename = await otherUser.put(`/api/files/${up.body.id}`).send({ name: 'hacked.md' });
|
|
202
|
+
assert.strictEqual(rename.status, 403);
|
|
203
|
+
// bob 尝试删除
|
|
204
|
+
const del = await otherUser.delete(`/api/files/${up.body.id}`);
|
|
205
|
+
assert.strictEqual(del.status, 403);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('普通用户不能批量删除他人文件 → 403', async () => {
|
|
209
|
+
const up = await user.post('/api/files/upload-json').send({
|
|
210
|
+
name: 'alice-batch.md',
|
|
211
|
+
content: 'x',
|
|
212
|
+
isPublic: false,
|
|
213
|
+
});
|
|
214
|
+
const res = await otherUser.post('/api/files/batch').send({ action: 'delete', ids: [up.body.id] });
|
|
215
|
+
assert.strictEqual(res.status, 403);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('公开文件任何登录用户都能访问', async () => {
|
|
219
|
+
const up = await user.post('/api/files/upload-json').send({
|
|
220
|
+
name: 'alice-public.md',
|
|
221
|
+
content: '# public',
|
|
222
|
+
isPublic: true,
|
|
223
|
+
});
|
|
224
|
+
const detail = await otherUser.get(`/api/files/${up.body.id}`);
|
|
225
|
+
assert.strictEqual(detail.status, 200);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('admin 可访问任意用户的私有文件', async () => {
|
|
229
|
+
const up = await user.post('/api/files/upload-json').send({
|
|
230
|
+
name: 'alice-admin-can-see.md',
|
|
231
|
+
content: 'private',
|
|
232
|
+
isPublic: false,
|
|
233
|
+
});
|
|
234
|
+
const detail = await admin.get(`/api/files/${up.body.id}`);
|
|
235
|
+
assert.strictEqual(detail.status, 200);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('短链 /s/:key 私有文件未登录 → 重定向', async () => {
|
|
239
|
+
const up = await user.post('/api/files/upload-json').send({
|
|
240
|
+
name: 'short-private.md',
|
|
241
|
+
content: '# private shortlink',
|
|
242
|
+
isPublic: false,
|
|
243
|
+
});
|
|
244
|
+
const key = up.body.share_key;
|
|
245
|
+
const res = await request(env.app).get(`/s/${key}`);
|
|
246
|
+
// 私有文件未登录访问:302 重定向到 /
|
|
247
|
+
assert.strictEqual(res.status, 302);
|
|
248
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// 文件管理集成测试:上传(json) / 列表 / 渲染 / 详情 / 删除 / 版本
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const request = require('supertest');
|
|
5
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
6
|
+
|
|
7
|
+
let env;
|
|
8
|
+
let agent;
|
|
9
|
+
|
|
10
|
+
test.before(async () => {
|
|
11
|
+
env = createTestEnv();
|
|
12
|
+
await env.ready();
|
|
13
|
+
agent = request.agent(env.app);
|
|
14
|
+
await agent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test.after(() => {
|
|
18
|
+
env.cleanup();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('upload-json 上传 Markdown → 200,返回 id + share_key', async () => {
|
|
22
|
+
const res = await agent.post('/api/files/upload-json').send({
|
|
23
|
+
name: 'test-doc.md',
|
|
24
|
+
content: '# 标题\n\n这是一段 **Markdown** 内容。\n\n```js\nconsole.log("hi");\n```',
|
|
25
|
+
isPublic: true,
|
|
26
|
+
});
|
|
27
|
+
assert.strictEqual(res.status, 200);
|
|
28
|
+
assert.ok(res.body.id);
|
|
29
|
+
assert.ok(res.body.share_key);
|
|
30
|
+
assert.strictEqual(res.body.file_type, 'markdown');
|
|
31
|
+
assert.strictEqual(res.body.is_public, 1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('upload-json 缺少 name → 400', async () => {
|
|
35
|
+
const res = await agent.post('/api/files/upload-json').send({ content: 'x' });
|
|
36
|
+
assert.strictEqual(res.status, 400);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('upload-json 不支持的扩展名 → 400', async () => {
|
|
40
|
+
const res = await agent.post('/api/files/upload-json').send({ name: 'a.txt', content: 'x' });
|
|
41
|
+
assert.strictEqual(res.status, 400);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('列表包含已上传文件', async () => {
|
|
45
|
+
const res = await agent.get('/api/files');
|
|
46
|
+
assert.strictEqual(res.status, 200);
|
|
47
|
+
assert.ok(Array.isArray(res.body.files));
|
|
48
|
+
assert.ok(res.body.files.length > 0);
|
|
49
|
+
assert.ok(res.body.pagination);
|
|
50
|
+
// 每个文件含 tags 数组
|
|
51
|
+
assert.ok(Array.isArray(res.body.files[0].tags));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('详情 GET /api/files/:id', async () => {
|
|
55
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'detail.md', content: '# 详情' });
|
|
56
|
+
const res = await agent.get(`/api/files/${up.body.id}`);
|
|
57
|
+
assert.strictEqual(res.status, 200);
|
|
58
|
+
assert.strictEqual(res.body.original_name, 'detail.md');
|
|
59
|
+
assert.ok(Array.isArray(res.body.tags));
|
|
60
|
+
assert.strictEqual(typeof res.body.starred, 'boolean');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('详情不存在 → 404', async () => {
|
|
64
|
+
const res = await agent.get('/api/files/999999');
|
|
65
|
+
assert.strictEqual(res.status, 404);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('原文 GET /api/files/:id/content', async () => {
|
|
69
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'content.md', content: '# 原文测试' });
|
|
70
|
+
const res = await agent.get(`/api/files/${up.body.id}/content`);
|
|
71
|
+
assert.strictEqual(res.status, 200);
|
|
72
|
+
assert.strictEqual(res.body.content, '# 原文测试');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('渲染 GET /api/files/:id/render → HTML', async () => {
|
|
76
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'render.md', content: '# 渲染\n\n$E=mc^2$' });
|
|
77
|
+
const res = await agent.get(`/api/files/${up.body.id}/render`);
|
|
78
|
+
assert.strictEqual(res.status, 200);
|
|
79
|
+
assert.match(res.headers['content-type'] || '', /text\/html/);
|
|
80
|
+
assert.ok(res.text.includes('<h1>'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('同名覆盖上传 → overwritten: true + 版本号递增', async () => {
|
|
84
|
+
await agent.post('/api/files/upload-json').send({ name: 'versioned.md', content: 'v1' });
|
|
85
|
+
const res = await agent.post('/api/files/upload-json').send({ name: 'versioned.md', content: 'v2' });
|
|
86
|
+
assert.strictEqual(res.status, 200);
|
|
87
|
+
assert.strictEqual(res.body.overwritten, true);
|
|
88
|
+
assert.ok(res.body.version >= 2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('版本列表 GET /api/files/:id/versions', async () => {
|
|
92
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'ver-list.md', content: 'a' });
|
|
93
|
+
await agent.post('/api/files/upload-json').send({ name: 'ver-list.md', content: 'b' });
|
|
94
|
+
const res = await agent.get(`/api/files/${up.body.id}/versions`);
|
|
95
|
+
assert.strictEqual(res.status, 200);
|
|
96
|
+
assert.ok(Array.isArray(res.body.versions));
|
|
97
|
+
assert.ok(res.body.versions.length >= 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('更新文件名 PUT /api/files/:id', async () => {
|
|
101
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'rename.md', content: 'x' });
|
|
102
|
+
const res = await agent.put(`/api/files/${up.body.id}`).send({ name: 'renamed.md' });
|
|
103
|
+
assert.strictEqual(res.status, 200);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('删除文件 DELETE /api/files/:id', async () => {
|
|
107
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'delete-me.md', content: 'x' });
|
|
108
|
+
const res = await agent.delete(`/api/files/${up.body.id}`);
|
|
109
|
+
assert.strictEqual(res.status, 200);
|
|
110
|
+
// 再查应 404
|
|
111
|
+
const gone = await agent.get(`/api/files/${up.body.id}`);
|
|
112
|
+
assert.strictEqual(gone.status, 404);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('FTS 搜索命中内容', async () => {
|
|
116
|
+
await agent.post('/api/files/upload-json').send({ name: 'searchable.md', content: '这是一个 uniquekeyword 标记的文档' });
|
|
117
|
+
const res = await agent.get('/api/files/search').query({ q: 'uniquekeyword' });
|
|
118
|
+
assert.strictEqual(res.status, 200);
|
|
119
|
+
assert.ok(res.body.files.length > 0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('标签:创建 + 关联到文件', async () => {
|
|
123
|
+
const tagRes = await agent.post('/api/tags').send({ name: '测试标签' });
|
|
124
|
+
assert.ok(tagRes.status === 200 || tagRes.status === 201);
|
|
125
|
+
const tagId = tagRes.body.id;
|
|
126
|
+
|
|
127
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'tagged.md', content: 'x' });
|
|
128
|
+
const linkRes = await agent.put(`/api/files/${up.body.id}/tags`).send({ tagIds: [tagId] });
|
|
129
|
+
assert.strictEqual(linkRes.status, 200);
|
|
130
|
+
|
|
131
|
+
const detail = await agent.get(`/api/files/${up.body.id}`);
|
|
132
|
+
assert.ok(detail.body.tags.some(t => t.id === tagId));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('收藏文件', async () => {
|
|
136
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'star.md', content: 'x' });
|
|
137
|
+
const starRes = await agent.post(`/api/files/${up.body.id}/star`);
|
|
138
|
+
assert.strictEqual(starRes.status, 200);
|
|
139
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 短链分享集成测试:/s/:key 公开/私有访问控制
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const request = require('supertest');
|
|
5
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
6
|
+
|
|
7
|
+
let env;
|
|
8
|
+
let agent;
|
|
9
|
+
|
|
10
|
+
test.before(async () => {
|
|
11
|
+
env = createTestEnv();
|
|
12
|
+
await env.ready();
|
|
13
|
+
agent = request.agent(env.app);
|
|
14
|
+
await agent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test.after(() => {
|
|
18
|
+
env.cleanup();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('公开文件:匿名访问短链 /s/:key → 200', async () => {
|
|
22
|
+
const up = await agent.post('/api/files/upload-json').send({
|
|
23
|
+
name: 'public.md',
|
|
24
|
+
content: '# 公开文档',
|
|
25
|
+
isPublic: true,
|
|
26
|
+
});
|
|
27
|
+
const res = await request(env.app).get(`/s/${up.body.share_key}`);
|
|
28
|
+
assert.strictEqual(res.status, 200);
|
|
29
|
+
assert.match(res.headers['content-type'] || '', /text\/html/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('私有文件:匿名访问短链 /s/:key → 重定向到首页', async () => {
|
|
33
|
+
const up = await agent.post('/api/files/upload-json').send({
|
|
34
|
+
name: 'private.md',
|
|
35
|
+
content: '# 私有文档',
|
|
36
|
+
isPublic: false,
|
|
37
|
+
});
|
|
38
|
+
const res = await request(env.app).get(`/s/${up.body.share_key}`);
|
|
39
|
+
assert.strictEqual(res.status, 302);
|
|
40
|
+
assert.strictEqual(res.headers.location, '/');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('不存在的短链 → 404', async () => {
|
|
44
|
+
const res = await request(env.app).get('/s/NOTEXIST');
|
|
45
|
+
assert.strictEqual(res.status, 404);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('公开文件渲染端点 /api/files/:id/render 匿名可访问', async () => {
|
|
49
|
+
const up = await agent.post('/api/files/upload-json').send({
|
|
50
|
+
name: 'pub-render.md',
|
|
51
|
+
content: '# 渲染',
|
|
52
|
+
isPublic: true,
|
|
53
|
+
});
|
|
54
|
+
const res = await request(env.app).get(`/api/files/${up.body.id}/render`);
|
|
55
|
+
assert.strictEqual(res.status, 200);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('私有文件:匿名访问 /api/files/:id → 401', async () => {
|
|
59
|
+
const up = await agent.post('/api/files/upload-json').send({
|
|
60
|
+
name: 'priv2.md',
|
|
61
|
+
content: '# 私有',
|
|
62
|
+
isPublic: false,
|
|
63
|
+
});
|
|
64
|
+
const res = await request(env.app).get(`/api/files/${up.body.id}`);
|
|
65
|
+
assert.strictEqual(res.status, 401);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('健康检查 /health → 200', async () => {
|
|
69
|
+
const res = await request(env.app).get('/health');
|
|
70
|
+
assert.strictEqual(res.status, 200);
|
|
71
|
+
assert.strictEqual(res.body.status, 'ok');
|
|
72
|
+
assert.ok(res.body.db === true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('SPA 兜底 / → 返回 HTML', async () => {
|
|
76
|
+
const res = await request(env.app).get('/');
|
|
77
|
+
assert.strictEqual(res.status, 200);
|
|
78
|
+
assert.match(res.headers['content-type'] || '', /text\/html/);
|
|
79
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Skills 集成测试:列表 / 详情 / 下载 zip / mcp/config 结构。全部 requireAuth。
|
|
2
|
+
// 挂载点 /api(/skills、/skills/:name、/skills/:name/download、/mcp/config)。
|
|
3
|
+
// 依赖仓库内 skills/jpage-upload/SKILL.md(内置 skill)。
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const assert = require('node:assert');
|
|
6
|
+
const request = require('supertest');
|
|
7
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
8
|
+
|
|
9
|
+
// supertest 二进制响应解析器:skills download 是流式 zip,需 buffer(true).parse 才能拿到字节。
|
|
10
|
+
function binaryParser(res, cb) {
|
|
11
|
+
const data = [];
|
|
12
|
+
res.on('data', chunk => data.push(chunk));
|
|
13
|
+
res.on('end', () => cb(null, Buffer.concat(data)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let env;
|
|
17
|
+
let agent;
|
|
18
|
+
|
|
19
|
+
test.before(async () => {
|
|
20
|
+
env = createTestEnv();
|
|
21
|
+
await env.ready();
|
|
22
|
+
agent = request.agent(env.app);
|
|
23
|
+
await agent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test.after(() => {
|
|
27
|
+
env.cleanup();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// --- 权限 ---
|
|
31
|
+
test('未登录 GET /api/skills → 401', async () => {
|
|
32
|
+
const res = await request(env.app).get('/api/skills');
|
|
33
|
+
assert.strictEqual(res.status, 401);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// --- 列表 ---
|
|
37
|
+
test('GET /api/skills → 200,含内置 jpage-upload skill', async () => {
|
|
38
|
+
const res = await agent.get('/api/skills');
|
|
39
|
+
assert.strictEqual(res.status, 200);
|
|
40
|
+
assert.ok(Array.isArray(res.body.skills));
|
|
41
|
+
// 仓库内置 jpage-upload skill 应被发现
|
|
42
|
+
assert.ok(res.body.skills.some(s => s.name === 'jpage-upload'), '应含 jpage-upload skill');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// --- 详情 ---
|
|
46
|
+
test('GET /api/skills/jpage-upload → 200', async () => {
|
|
47
|
+
const res = await agent.get('/api/skills/jpage-upload');
|
|
48
|
+
assert.strictEqual(res.status, 200);
|
|
49
|
+
assert.strictEqual(res.body.name, 'jpage-upload');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('GET /api/skills/不存在 → 404', async () => {
|
|
53
|
+
const res = await agent.get('/api/skills/no-such-skill-xyz');
|
|
54
|
+
assert.strictEqual(res.status, 404);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// --- 下载 ---
|
|
58
|
+
test('GET /api/skills/jpage-upload/download → 200,application/zip', async () => {
|
|
59
|
+
const res = await agent.get('/api/skills/jpage-upload/download').buffer(true).parse(binaryParser);
|
|
60
|
+
assert.strictEqual(res.status, 200);
|
|
61
|
+
assert.match(res.headers['content-type'] || '', /application\/zip/);
|
|
62
|
+
// Content-Disposition 是附件
|
|
63
|
+
assert.match(res.headers['content-disposition'] || '', /attachment/);
|
|
64
|
+
// zip 魔数 PK
|
|
65
|
+
assert.ok(Buffer.isBuffer(res.body));
|
|
66
|
+
assert.ok(res.body.length > 4);
|
|
67
|
+
assert.strictEqual(res.body[0], 0x50); // 'P'
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('GET /api/skills/不存在/download → 404', async () => {
|
|
71
|
+
const res = await agent.get('/api/skills/no-such-skill-xyz/download');
|
|
72
|
+
assert.strictEqual(res.status, 404);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// --- mcp/config ---
|
|
76
|
+
test('GET /api/mcp/config → 200,含 config.mcpServers.jpage', async () => {
|
|
77
|
+
const res = await agent.get('/api/mcp/config');
|
|
78
|
+
assert.strictEqual(res.status, 200);
|
|
79
|
+
assert.ok(res.body.config);
|
|
80
|
+
assert.ok(res.body.config.mcpServers);
|
|
81
|
+
assert.ok(res.body.config.mcpServers.jpage);
|
|
82
|
+
assert.ok(res.body.config.mcpServers.jpage.url);
|
|
83
|
+
assert.strictEqual(res.body.config.mcpServers.jpage.type, 'http');
|
|
84
|
+
// tokens 是当前用户的 token 列表
|
|
85
|
+
assert.ok(Array.isArray(res.body.tokens));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('GET /api/mcp/config → 200,含多客户端 configs 数组', async () => {
|
|
89
|
+
const res = await agent.get('/api/mcp/config');
|
|
90
|
+
assert.strictEqual(res.status, 200);
|
|
91
|
+
assert.ok(Array.isArray(res.body.configs));
|
|
92
|
+
// 5 个客户端:claude-code / claude-desktop / cursor / zcode / generic
|
|
93
|
+
assert.strictEqual(res.body.configs.length, 5);
|
|
94
|
+
const ids = res.body.configs.map(c => c.id);
|
|
95
|
+
for (const id of ['claude-code', 'claude-desktop', 'cursor', 'zcode', 'generic']) {
|
|
96
|
+
assert.ok(ids.includes(id), `configs 应包含 ${id}`);
|
|
97
|
+
}
|
|
98
|
+
// 每项含 label / path / config.mcpServers.jpage
|
|
99
|
+
res.body.configs.forEach(c => {
|
|
100
|
+
assert.ok(c.label, `${c.id} 应有 label`);
|
|
101
|
+
assert.ok('path' in c, `${c.id} 应有 path`);
|
|
102
|
+
assert.ok(c.config && c.config.mcpServers && c.config.mcpServers.jpage, `${c.id} config 应含 mcpServers.jpage`);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// 标签集成测试:创建(重复返回现有)/ 列表(role 区分 file_count)/ 删除(级联 file_tags)/ 边界。
|
|
2
|
+
// 挂载点 /api/tags,全部 requireAuth。
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert');
|
|
5
|
+
const request = require('supertest');
|
|
6
|
+
const { createTestEnv } = require('../helpers/setup');
|
|
7
|
+
|
|
8
|
+
let env;
|
|
9
|
+
let agent;
|
|
10
|
+
|
|
11
|
+
test.before(async () => {
|
|
12
|
+
env = createTestEnv();
|
|
13
|
+
await env.ready();
|
|
14
|
+
agent = request.agent(env.app);
|
|
15
|
+
await agent.post('/api/auth/login').send({ username: 'admin', password: 'testpassword123' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test.after(() => {
|
|
19
|
+
env.cleanup();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// --- 权限边界 ---
|
|
23
|
+
test('未登录 GET /api/tags → 401', async () => {
|
|
24
|
+
const res = await request(env.app).get('/api/tags');
|
|
25
|
+
assert.strictEqual(res.status, 401);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// --- 创建 ---
|
|
29
|
+
test('创建标签:空名 → 400', async () => {
|
|
30
|
+
const res = await agent.post('/api/tags').send({ name: ' ' });
|
|
31
|
+
assert.strictEqual(res.status, 400);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('创建标签:happy path → 200,返回 id', async () => {
|
|
35
|
+
const res = await agent.post('/api/tags').send({ name: '前端' });
|
|
36
|
+
assert.strictEqual(res.status, 200);
|
|
37
|
+
assert.ok(res.body.id);
|
|
38
|
+
assert.strictEqual(res.body.name, '前端');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('创建标签:重名返回现有(不报错)→ 200,id 相同', async () => {
|
|
42
|
+
const first = await agent.post('/api/tags').send({ name: '重复标签' });
|
|
43
|
+
const second = await agent.post('/api/tags').send({ name: '重复标签' });
|
|
44
|
+
assert.strictEqual(second.status, 200);
|
|
45
|
+
assert.strictEqual(first.body.id, second.body.id);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- 列表 ---
|
|
49
|
+
test('列表 GET /api/tags → 200,含 file_count', async () => {
|
|
50
|
+
const res = await agent.get('/api/tags');
|
|
51
|
+
assert.strictEqual(res.status, 200);
|
|
52
|
+
assert.ok(Array.isArray(res.body.tags));
|
|
53
|
+
assert.ok(res.body.tags.length > 0);
|
|
54
|
+
// 每项含 file_count
|
|
55
|
+
assert.strictEqual(typeof res.body.tags[0].file_count, 'number');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// --- 删除 + 级联 ---
|
|
59
|
+
test('删除标签:happy path → 200,列表中消失', async () => {
|
|
60
|
+
const create = await agent.post('/api/tags').send({ name: '待删除' });
|
|
61
|
+
const del = await agent.delete(`/api/tags/${create.body.id}`);
|
|
62
|
+
assert.strictEqual(del.status, 200);
|
|
63
|
+
assert.strictEqual(del.body.success, true);
|
|
64
|
+
const list = await agent.get('/api/tags');
|
|
65
|
+
assert.ok(!list.body.tags.some(t => t.id === create.body.id));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('删除标签:不存在 → 404', async () => {
|
|
69
|
+
const res = await agent.delete('/api/tags/999999');
|
|
70
|
+
assert.strictEqual(res.status, 404);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('删除标签后,文件-标签关联被级联清理', async () => {
|
|
74
|
+
// 建标签 + 建文件 + 关联
|
|
75
|
+
const tagRes = await agent.post('/api/tags').send({ name: '级联测试标签' });
|
|
76
|
+
const tagId = tagRes.body.id;
|
|
77
|
+
const up = await agent.post('/api/files/upload-json').send({ name: 'cascade.md', content: 'x' });
|
|
78
|
+
await agent.put(`/api/files/${up.body.id}/tags`).send({ tagIds: [tagId] });
|
|
79
|
+
// 删标签
|
|
80
|
+
await agent.delete(`/api/tags/${tagId}`);
|
|
81
|
+
// 文件详情里 tags 不应再含该标签
|
|
82
|
+
const detail = await agent.get(`/api/files/${up.body.id}`);
|
|
83
|
+
assert.ok(!detail.body.tags.some(t => t.id === tagId));
|
|
84
|
+
});
|