dsh-notify-say 0.4.21 → 0.4.25
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/README.md +216 -176
- package/docs/audio-audit/README.md +164 -0
- package/docs/audio-audit/fetch-snapshots.ps1 +76 -0
- package/docs/audio-audit/snapshots/ameharu-hau.txt +89 -0
- package/docs/audio-audit/snapshots/goki-nuigurumi.txt +73 -0
- package/docs/audio-audit/snapshots/kasukabe-tsumugi.txt +197 -0
- package/docs/audio-audit/snapshots/manbetsu-hanamaru.txt +27 -0
- package/docs/audio-audit/snapshots/meimei-meria.txt +84 -0
- package/docs/audio-audit/snapshots/mochiko.txt +27 -0
- package/docs/audio-audit/snapshots/namiine-ritsu.txt +76 -0
- package/docs/audio-audit/snapshots/shirakami-kotaro.txt +158 -0
- package/docs/audio-audit/snapshots/sss-anonymous-common.txt +142 -0
- package/docs/audio-audit/snapshots/sss-zundamon.txt +142 -0
- package/docs/audio-audit/snapshots/voicevox-software.txt +41 -0
- package/docs/audio-audit/snapshots/voicevox-vvm-terms.txt +385 -0
- package/docs/audio-audit/snapshots/yurei-chan.txt +112 -0
- package/docs//345/276/205/345/201/232/346/270/205/345/215/225.md +218 -0
- package/docs//350/257/255/351/237/263/345/214/205/345/210/206/345/217/221/346/226/271/346/241/210-/345/276/205/345/256/241/351/230/205.md +378 -0
- package/lib/client.js +50 -8
- package/lib/config-route.js +251 -209
- package/lib/http-guard.js +222 -0
- package/lib/index.js +1822 -1768
- package/lib/session-bridge.js +138 -117
- package/package.json +11 -2
- package/test/audio-source.test.mjs +184 -0
- package/test/audio-wiring.test.mjs +287 -0
- package/test/client-bundle.test.mjs +671 -0
- package/test/client-contract.test.mjs +118 -0
- package/test/config-coverage.test.mjs +109 -0
- package/test/config-route.test.mjs +212 -0
- package/test/config-schema.test.mjs +147 -0
- package/test/config-store.test.mjs +256 -0
- package/test/dev-reload.test.mjs +132 -0
- package/test/effective-for-client.test.mjs +142 -0
- package/test/fail-route.test.mjs +128 -0
- package/test/fail-voice.test.mjs +193 -0
- package/test/http-guard.test.mjs +143 -0
- package/test/http-integration.test.mjs +284 -0
- package/test/idle-gate.test.mjs +167 -0
- package/test/preview.test.mjs +92 -0
- package/test/real-cordis.test.mjs +204 -0
- package/test/release-voice-guard.test.mjs +185 -0
- package/test/reload-live.test.mjs +261 -0
- package/test/reload-verification.test.mjs +218 -0
- package/test/route-loader-register.mjs +22 -0
- package/test/scalar-guard.test.mjs +125 -0
- package/test/session-jump.test.mjs +266 -0
- package/test/stub-child-process.mjs +43 -0
- package/test/tts-lines.test.mjs +112 -0
- package/test/version-consistency.test.mjs +98 -0
- package/test/voice-availability.test.mjs +90 -0
- package/test/wav-gain.test.mjs +138 -0
- package/test/wiring.test.mjs +287 -0
- package/tools/notify-config.ps1 +472 -471
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-desktop 失败语音(fail 池)测试 —— v0.3.1。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖两处:
|
|
5
|
+
* A. lib/index.js:error 分支必须走 psKind='fail';blocked 必须仍是 'chime'
|
|
6
|
+
* (语义不同:error=跑了但失败,blocked=被拦下没跑起来)。
|
|
7
|
+
* B. dsh-notify.ps1:fail 池**不得**回落到 done 池 / master-voice / 梗音效池
|
|
8
|
+
* ——否则失败时会播「主人、任務完了です!」,语义完全相反。
|
|
9
|
+
*
|
|
10
|
+
* B 用 PowerShell 子进程真跑 ps1(-Preview 只出声不弹 Toast),断言其 pick= 字段。
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ **本文件故意不 import ../lib/index.js,也不改 SystemRoot**(三条实测踩坑,别踩):
|
|
13
|
+
* ① 别的测试文件用 `process.env.SystemRoot = '<不存在>'` 来让 spawn 失效;
|
|
14
|
+
* 若本文件也这么干,**PowerShell 子进程会启动失败**(报 `8009001d`
|
|
15
|
+
* "Loading managed Windows PowerShell failed"——CLR 找不到 .NET),
|
|
16
|
+
* 表现为 stdout 全空、5 条断言全红,**极易误判成"代码坏了"**。
|
|
17
|
+
* 本文件只读源码文本、只跑 ps1,不需要那个隔离。
|
|
18
|
+
* ② 不能用 `node --test ... -- <args>` 传参:`node --test` 会把 `--` 之后的参数当
|
|
19
|
+
* **测试文件**加载,`.ps1` 触发 `ERR_UNKNOWN_FILE_EXTENSION`,且 argv 取不到值。
|
|
20
|
+
* ③ 不能用 `execFileSync` 抓 stdout(本机 `node --test` 下拿不到);用 `spawnSync`。
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ worktree 长路径限制(实测):MCI 在 >130 字符的 wav 路径上 open 返回 304,
|
|
23
|
+
* 故本文件**只断言"选到了哪个文件"**(pick= 字段,纯字符串判定),
|
|
24
|
+
* **不断言"是否真的出声"**——后者在 worktree 里必假失败,是环境限制非代码缺陷。
|
|
25
|
+
* 真机播放已在短路径(模拟部署)下人工验证通过。
|
|
26
|
+
*
|
|
27
|
+
* 运行:node --test tools/plugins/dsh-notify-desktop/test/
|
|
28
|
+
*/
|
|
29
|
+
import { test } from 'node:test';
|
|
30
|
+
import assert from 'node:assert/strict';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const PLUGIN = join(HERE, '..');
|
|
38
|
+
const PS1 = join(PLUGIN, 'dsh-notify.ps1');
|
|
39
|
+
const FAIL_POOL = join(PLUGIN, 'sounds', 'voice-lines', 'fail');
|
|
40
|
+
/** done 池 = voice-lines 根目录(那批是说「任務完了です」的,失败时不该播)。 */
|
|
41
|
+
const DONE_POOL = join(PLUGIN, 'sounds', 'voice-lines');
|
|
42
|
+
const LIB = join(PLUGIN, 'lib', 'index.js');
|
|
43
|
+
|
|
44
|
+
/** 跑 ps1 -Preview,返回 stdout / stderr / exit code。用 spawnSync(见文件头踩坑③)。 */
|
|
45
|
+
function runPs1(args) {
|
|
46
|
+
const r = spawnSync('powershell',
|
|
47
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', PS1, ...args],
|
|
48
|
+
{ encoding: 'utf8', timeout: 60_000 });
|
|
49
|
+
return {
|
|
50
|
+
out: String(r.stdout || ''),
|
|
51
|
+
err: String(r.stderr || ''),
|
|
52
|
+
code: typeof r.status === 'number' ? r.status : 1,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 从输出里提取 pick=<name>。 */
|
|
57
|
+
function pickOf(out) {
|
|
58
|
+
const m = /pick=([^\s+]+)/.exec(out);
|
|
59
|
+
return m ? m[1] : '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── A. lib 源码分支路由(读源码文本断言,不起进程)─────────────────────────
|
|
63
|
+
test('A1: lib 源码中 error 分支走 fail 池,blocked 仍走 chime(exact)', () => {
|
|
64
|
+
const src = readFileSync(LIB, 'utf8');
|
|
65
|
+
assert.match(src, /gate = cfg\.notifyOnError; psKind = 'fail';/,
|
|
66
|
+
"error 分支必须设为 psKind='fail'(否则失败不播 fail 池)");
|
|
67
|
+
assert.match(src, /gate = cfg\.notifyOnBlocked; psKind = 'chime';/,
|
|
68
|
+
"blocked 必须保持 psKind='chime'(它是'被拦下没跑起来',不是'跑了但失败')");
|
|
69
|
+
// 负例:error 不得再是 chime —— 那正是本次要改掉的旧行为
|
|
70
|
+
assert.doesNotMatch(src, /gate = cfg\.notifyOnError; psKind = 'chime';/,
|
|
71
|
+
"error 不应再走 chime");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── B. ps1 池选择与语义护栏(真跑 ps1)──────────────────────────────────────
|
|
75
|
+
test('B0: fail 池资产存在且为 48 条(3 句 × 16 音色)', () => {
|
|
76
|
+
assert.equal(existsSync(FAIL_POOL), true, 'fail 池目录必须存在');
|
|
77
|
+
const files = readdirSync(FAIL_POOL).filter((f) => f.endsWith('.wav'));
|
|
78
|
+
|
|
79
|
+
// ⚠️ 不能硬编码"48 条 / 16 音色"。
|
|
80
|
+
//
|
|
81
|
+
// 自用版有 16 个音色的 fail 语音,但**发布版只带 1 个音色**。
|
|
82
|
+
// 硬编码会在发布包里必然失败 —— 而那个失败是"测试假设了开发环境",
|
|
83
|
+
// 不是资产真的有问题。外部用户跑 `npm test` 看到红灯,会以为包坏了。
|
|
84
|
+
//
|
|
85
|
+
// 判据改为:**按 fail 池自己出现的音色集合**推算应有条数。
|
|
86
|
+
// 每个音色在 fail 池应有 3 个变体(~1/~2/~3)。
|
|
87
|
+
const byVoice = new Map();
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
const m = /^(.+)~(\d)\.wav$/.exec(f);
|
|
90
|
+
if (!m) continue;
|
|
91
|
+
const [, base, n] = m;
|
|
92
|
+
if (!byVoice.has(base)) byVoice.set(base, new Map());
|
|
93
|
+
byVoice.get(base).set(n, (byVoice.get(base).get(n) || 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
assert.ok(byVoice.size > 0, 'fail 池至少要有一个音色');
|
|
96
|
+
|
|
97
|
+
// 每个音色的三个变体都应恰好各 1 条(不是"某些音色有 3 条、某些只有 1 条")
|
|
98
|
+
for (const [base, variants] of byVoice) {
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
[...variants.keys()].sort(),
|
|
101
|
+
['1', '2', '3'],
|
|
102
|
+
`音色 ${base} 应恰好有 ~1/~2/~3 三个变体,实际 ${[...variants.keys()].join(',')}`,
|
|
103
|
+
);
|
|
104
|
+
for (const [n, count] of variants) {
|
|
105
|
+
assert.equal(count, 1, `音色 ${base} 的 ~${n} 变体不应重复(实际 ${count} 条)`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 总数 = 音色数 × 3
|
|
110
|
+
assert.equal(
|
|
111
|
+
files.length,
|
|
112
|
+
byVoice.size * 3,
|
|
113
|
+
`fail 池应为 ${byVoice.size} 音色 × 3 变体 = ${byVoice.size * 3} 条,实际 ${files.length}`,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('B1: -Kind fail + 固定音色 → 选到 fail 池的 ~N 变体(不是 done 池同名文件)', () => {
|
|
118
|
+
const { out, err, code } = runPs1(['-Kind', 'fail', '-Preview', '-Voice', 'zundamon-normal', '-Volume', '0']);
|
|
119
|
+
assert.equal(code, 0, `ps1 应正常退出;stderr=${err.slice(0, 200)}`);
|
|
120
|
+
const pick = pickOf(out);
|
|
121
|
+
assert.match(pick, /^zundamon-normal~\d\.wav$/, `应选 fail 池 ~N 变体,实际 pick=${pick}`);
|
|
122
|
+
// 负例:不得选到 done 池根目录的同名(无 ~)文件
|
|
123
|
+
assert.doesNotMatch(pick, /^zundamon-normal\.wav$/, '不得选到 done 池根目录的同名文件');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('B2: -Kind fail + random → 仍限定在 fail 池(不回落到庆祝音)', () => {
|
|
127
|
+
const { out, code } = runPs1(['-Kind', 'fail', '-Preview', '-Mode', 'random', '-Volume', '0']);
|
|
128
|
+
assert.equal(code, 0, 'ps1 应正常退出');
|
|
129
|
+
const pick = pickOf(out);
|
|
130
|
+
assert.match(pick, /~\d\.wav$/, `random 应选 fail 池 ~N 变体,实际 pick=${pick}`);
|
|
131
|
+
assert.doesNotMatch(pick, /master-voice/, '不得回落 master-voice(那是"任務完了")');
|
|
132
|
+
assert.doesNotMatch(pick, /\.mp3$/, '不得回落梗音效池(庆祝音)');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('B3: fail 池缺该音色 → 在 fail 池内换音色,**绝不回落到 done 池**(关键护栏)', () => {
|
|
136
|
+
// 独立复核 I-1 提出后(2026-10-17)行为已加强:池内换音色 > 没台词。
|
|
137
|
+
// 用 done 池里真实存在、但 fail 池不覆盖的 key(如 -kansai-1 变体)来测这条路径。
|
|
138
|
+
const { out, code } = runPs1(['-Kind', 'fail', '-Preview', '-Voice', 'ameharu-hau-kansai-1', '-Volume', '0']);
|
|
139
|
+
assert.equal(code, 0, 'ps1 应正常退出');
|
|
140
|
+
const pick = pickOf(out);
|
|
141
|
+
|
|
142
|
+
// ① 必须仍从 fail 池取到台词(不能因为缺该音色就哑掉)
|
|
143
|
+
assert.match(pick, /~\d\.wav$/, `应在 fail 池内换音色,实际 pick=${pick}`);
|
|
144
|
+
|
|
145
|
+
// ② 本测试最重要的一条:绝不回落到 done 池(那会播「主人、任務完了です!」语义相反)。
|
|
146
|
+
// ⚠️ 不能写 `assert.doesNotMatch(out, /pick=\S*voice-lines\\/)`——`pick=` 只含**叶文件名**、
|
|
147
|
+
// 从不含目录,该断言**永远为真 = 空断言**(自查时发现并改掉)。
|
|
148
|
+
// 有效做法:拿 done 池根目录的真实文件名清单,断言 pick 不等于其中任何一个。
|
|
149
|
+
const doneNames = new Set(readdirSync(DONE_POOL).filter((f) => f.endsWith('.wav')));
|
|
150
|
+
assert.ok(!doneNames.has(pick), `pick=${pick} 落在 done 池里(说明回落到"完成"语音)`);
|
|
151
|
+
|
|
152
|
+
// ③ 也不许回落到 master-voice / 梗音效(庆祝音)
|
|
153
|
+
assert.doesNotMatch(out, /master-voice/, '不得回落 master-voice');
|
|
154
|
+
assert.doesNotMatch(pick, /\.mp3$/, '不得回落梗音效池');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('B3b: 完全不存在该音色 → 仍在 fail 池内取台词(不哑)', () => {
|
|
158
|
+
const { out, code } = runPs1(['-Kind', 'fail', '-Preview', '-Voice', 'no-such-voice-xyz', '-Volume', '0']);
|
|
159
|
+
assert.equal(code, 0, 'ps1 应正常退出');
|
|
160
|
+
const pick = pickOf(out);
|
|
161
|
+
assert.match(pick, /~\d\.wav$/, `应在 fail 池内换音色,实际 pick=${pick}`);
|
|
162
|
+
const doneNames = new Set(readdirSync(DONE_POOL).filter((f) => f.endsWith('.wav')));
|
|
163
|
+
assert.ok(!doneNames.has(pick), `pick=${pick} 落在 done 池里`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('B3c: fail 池整目录缺失 → 落到蜂鸣(不变哑巴,也不越池到 done)', () => {
|
|
167
|
+
// 用 chime 模拟"子池取不到"这层保护:chime 恒不取语句,其兜底必须是蜂鸣而非 done 池。
|
|
168
|
+
const { out, code } = runPs1(['-Kind', 'chime', '-Preview', '-Volume', '0']);
|
|
169
|
+
assert.equal(code, 0, 'ps1 应正常退出');
|
|
170
|
+
assert.equal(pickOf(out), 'chime', 'chime 应标为 chime(走蜂鸣)');
|
|
171
|
+
assert.doesNotMatch(out, /pick=\S+\.wav/, 'chime 不得取到任何语句文件');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('B4: -Kind chime → 不选语句(pick 标为 chime,只蜂鸣)', () => {
|
|
175
|
+
const { out, code } = runPs1(['-Kind', 'chime', '-Preview', '-Volume', '0']);
|
|
176
|
+
assert.equal(code, 0, 'ps1 应正常退出');
|
|
177
|
+
// 既有行为:chime 的展示标签就是 'chime'(ps1 的 pickedName 分支),不是 'none'。
|
|
178
|
+
// 关键断言是「没有选到任何 wav 语句」——chime 必须既不是 fail 池也不是 done 池。
|
|
179
|
+
assert.equal(pickOf(out), 'chime', `chime 的 pick 标签应为 chime,实际=${pickOf(out)}`);
|
|
180
|
+
assert.doesNotMatch(out, /pick=\S+\.wav/, 'chime 不得选到任何语音文件');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('B5: -Kind done 未回归(仍从 done 池选)', () => {
|
|
184
|
+
const { out, code } = runPs1(['-Kind', 'done', '-Preview', '-Voice', 'zundamon-normal', '-Volume', '0']);
|
|
185
|
+
assert.equal(code, 0, 'ps1 应正常退出');
|
|
186
|
+
const pick = pickOf(out);
|
|
187
|
+
assert.match(pick, /^(zundamon-normal|master-voice)\.wav$/, `done 应走 done 池/master,实际 pick=${pick}`);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('B6: 未知 Kind 被 ValidateSet 拒绝(fail-closed,不会静默走错池)', () => {
|
|
191
|
+
const { code } = runPs1(['-Kind', 'bogus-kind', '-Preview']);
|
|
192
|
+
assert.notEqual(code, 0, 'ValidateSet 应拒绝未知 Kind');
|
|
193
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 路由防护测试(v0.4.24)。
|
|
3
|
+
*
|
|
4
|
+
* ## 背景:这不是理论风险,是实测出来的
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-18 实测:
|
|
7
|
+
* GET /api → 401 宿主有鉴权
|
|
8
|
+
* POST /api/dsh-notify-say/config/set(**不带任何凭据**)→ 200,配置真的被改
|
|
9
|
+
*
|
|
10
|
+
* 也就是说插件通过 `webServer.register()` 注册的路由**绕过了宿主鉴权**。
|
|
11
|
+
* 恶意网页可以用 `<img>` / 自动提交的 `<form>` 打到 127.0.0.1 上,
|
|
12
|
+
* 这类"简单请求"不触发 CORS 预检,所以浏览器不会拦。
|
|
13
|
+
*
|
|
14
|
+
* ## 本测试锁住什么
|
|
15
|
+
*
|
|
16
|
+
* 1. 防护函数的判定规则(单元级,纯逻辑)
|
|
17
|
+
* 2. 源码里确实接上了防护(防"写了模块但没接线")
|
|
18
|
+
* 3. 限流确实生效
|
|
19
|
+
*/
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { join, resolve, dirname } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
import { checkLocalOrigin, throttle, __resetThrottle } from '../lib/http-guard.js';
|
|
27
|
+
|
|
28
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const GOOD = 'http://127.0.0.1:62120';
|
|
30
|
+
|
|
31
|
+
/** 造一个 req 形状。 */
|
|
32
|
+
function req(headers) {
|
|
33
|
+
return { headers };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── H1: 本机来源放行 ────────────────────────────────────────────────────
|
|
37
|
+
test('H1: 本机回环 Origin 的读请求放行', () => {
|
|
38
|
+
for (const origin of [GOOD, 'http://localhost:62120', 'http://[::1]:62120']) {
|
|
39
|
+
const r = checkLocalOrigin(req({ origin }));
|
|
40
|
+
assert.equal(r.ok, true, `${origin} 应放行,实际拒绝:${r.reason}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── H2: 外部 Origin 必须拒绝(核心:恶意网页)───────────────────────────
|
|
45
|
+
test('H2: 外部域 Origin 一律拒绝', () => {
|
|
46
|
+
for (const origin of [
|
|
47
|
+
'https://evil.com',
|
|
48
|
+
'http://evil.com:62120',
|
|
49
|
+
'https://127.0.0.1.evil.com', // 前缀伪装
|
|
50
|
+
'file://',
|
|
51
|
+
]) {
|
|
52
|
+
const r = checkLocalOrigin(req({ origin }));
|
|
53
|
+
assert.equal(r.ok, false, `${origin} 必须拒绝`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── H3: null Origin 拒绝(沙箱 iframe / file://)────────────────────────
|
|
58
|
+
test('H3: Origin: null 拒绝', () => {
|
|
59
|
+
const r = checkLocalOrigin(req({ origin: 'null' }));
|
|
60
|
+
assert.equal(r.ok, false);
|
|
61
|
+
assert.equal(r.reason, 'null-origin');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── H4: Sec-Fetch-Site: cross-site 优先拒绝 ─────────────────────────────
|
|
65
|
+
test('H4: Sec-Fetch-Site 为 cross-site 时拒绝(即使 Origin 看着正常)', () => {
|
|
66
|
+
const r = checkLocalOrigin(req({
|
|
67
|
+
origin: GOOD,
|
|
68
|
+
'sec-fetch-site': 'cross-site',
|
|
69
|
+
}));
|
|
70
|
+
assert.equal(r.ok, false);
|
|
71
|
+
assert.equal(r.reason, 'cross-site');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── H5: 写操作必须 JSON(堵死表单提交)──────────────────────────────────
|
|
75
|
+
test('H5: 写操作用表单 content-type 时拒绝', () => {
|
|
76
|
+
const r = checkLocalOrigin(
|
|
77
|
+
req({ origin: GOOD, 'content-type': 'application/x-www-form-urlencoded' }),
|
|
78
|
+
{ write: true },
|
|
79
|
+
);
|
|
80
|
+
assert.equal(r.ok, false);
|
|
81
|
+
assert.equal(r.reason, 'write-requires-json',
|
|
82
|
+
'<form> 发不出 application/json —— 强制 JSON 等于堵死跨站表单提交');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── H6: 写操作无 Origin 也拒绝 ──────────────────────────────────────────
|
|
86
|
+
test('H6: 写操作缺少 Origin/Referer 时拒绝', () => {
|
|
87
|
+
const r = checkLocalOrigin(
|
|
88
|
+
req({ 'content-type': 'application/json' }),
|
|
89
|
+
{ write: true },
|
|
90
|
+
);
|
|
91
|
+
assert.equal(r.ok, false);
|
|
92
|
+
assert.equal(r.reason, 'write-without-origin');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── H7: 正常写操作放行 ──────────────────────────────────────────────────
|
|
96
|
+
test('H7: 本机 Origin + JSON 的写操作放行', () => {
|
|
97
|
+
const r = checkLocalOrigin(
|
|
98
|
+
req({ origin: GOOD, 'content-type': 'application/json; charset=utf-8' }),
|
|
99
|
+
{ write: true },
|
|
100
|
+
);
|
|
101
|
+
assert.equal(r.ok, true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── H8: 限流生效 ────────────────────────────────────────────────────────
|
|
105
|
+
test('H8: throttle 在间隔内拒绝第二次', () => {
|
|
106
|
+
__resetThrottle();
|
|
107
|
+
assert.equal(throttle('preview', 3000).ok, true, '第一次应放行');
|
|
108
|
+
const second = throttle('preview', 3000);
|
|
109
|
+
assert.equal(second.ok, false, '间隔内第二次必须拒绝');
|
|
110
|
+
assert.ok(second.retryAfterMs > 0, '应给出剩余等待时间');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── H9: 不同路由的限流互不影响 ──────────────────────────────────────────
|
|
114
|
+
test('H9: 不同 key 的限流相互独立', () => {
|
|
115
|
+
__resetThrottle();
|
|
116
|
+
assert.equal(throttle('preview', 3000).ok, true);
|
|
117
|
+
assert.equal(throttle('selftest', 3000).ok, true,
|
|
118
|
+
'selftest 不该被 preview 的限流影响');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── H10: 源码确实接上了防护(防"写了模块没接线")────────────────────────
|
|
122
|
+
test('H10: index.js 实际调用了 checkLocalOrigin 与 throttle', () => {
|
|
123
|
+
const src = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
|
|
124
|
+
assert.match(src, /import\s*\{[^}]*checkLocalOrigin/,
|
|
125
|
+
'必须 import checkLocalOrigin');
|
|
126
|
+
assert.match(src, /checkLocalOrigin\(req/,
|
|
127
|
+
'必须在请求处理里调用 checkLocalOrigin');
|
|
128
|
+
assert.match(src, /throttle\(/,
|
|
129
|
+
'必须对 preview/selftest 调用 throttle');
|
|
130
|
+
assert.match(src, /reason: guard\.reason/,
|
|
131
|
+
'拒绝时应把 reason 回给调用方,便于诊断');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── H11: config-route 必须有请求体上限 ──────────────────────────────────
|
|
135
|
+
test('H11: config-route 有请求体大小上限(不只是超时)', () => {
|
|
136
|
+
const src = readFileSync(join(ROOT, 'lib', 'config-route.js'), 'utf8');
|
|
137
|
+
assert.match(src, /MAX_BODY_BYTES/,
|
|
138
|
+
'必须有大小上限常量 —— 3 秒超时不等于限流,慢速大包能绕过');
|
|
139
|
+
assert.match(src, /__tooLarge/,
|
|
140
|
+
'超限必须能被调用方识别');
|
|
141
|
+
assert.match(src, /413/,
|
|
142
|
+
'超限应回 413(与 400 格式错区分)');
|
|
143
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 端到端集成测试(v0.4.25)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这类测试必须有
|
|
5
|
+
*
|
|
6
|
+
* 2026-10-18 的教训:我为 `/consume` 加了"写操作必须 JSON"的防护,
|
|
7
|
+
* 但客户端仍然用 **GET 无 body** 调用它 —— 结果实测 `GET /consume → 403`,
|
|
8
|
+
* 会话跳转刚修好又被我自己打断。
|
|
9
|
+
*
|
|
10
|
+
* 单元测试全绿(H1~H11),因为它们各自只看自己那一半:
|
|
11
|
+
* · http-guard 的测试说"写操作要 JSON" ✅
|
|
12
|
+
* · client 的测试说"返回值是对象" ✅
|
|
13
|
+
* · **没有测试把两端接起来跑一次真实请求** ❌
|
|
14
|
+
*
|
|
15
|
+
* 所以这个文件起一个**真实的 HTTP 服务器**(用 Node 内置 http),
|
|
16
|
+
* 把插件的真实 handler 挂上去,再用**客户端真实的调用方式**打过去。
|
|
17
|
+
*
|
|
18
|
+
* 这样"客户端怎么发"和"服务端怎么判"就在同一个测试里对齐了。
|
|
19
|
+
*/
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { createServer } from 'node:http';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { join, resolve, dirname } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { once } from 'node:events';
|
|
27
|
+
|
|
28
|
+
import { createConfigRoute } from '../lib/config-route.js';
|
|
29
|
+
import { createSessionBridge, ROUTE_PREFIX } from '../lib/session-bridge.js';
|
|
30
|
+
import { checkLocalOrigin, throttle, __resetThrottle } from '../lib/http-guard.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 起一个临时服务器,把「路由分流 + guard」按 index.js 的方式接起来。
|
|
34
|
+
*
|
|
35
|
+
* 刻意**复制** index.js 的分流逻辑而不是 import 它:
|
|
36
|
+
* index.js 会加载整个插件(依赖 DSH 上下文),在纯 Node 里跑不起来。
|
|
37
|
+
* 代价是这份逻辑与 index.js 有重复 —— 所以下面 E7 会断言两者的
|
|
38
|
+
* 关键判据一致,防止单边漂移。
|
|
39
|
+
*/
|
|
40
|
+
async function startServer() {
|
|
41
|
+
const bridge = createSessionBridge();
|
|
42
|
+
const configRoute = createConfigRoute({
|
|
43
|
+
read: () => ({}),
|
|
44
|
+
effective: () => ({}),
|
|
45
|
+
notifyChanged: () => {},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const server = createServer((req, res) => {
|
|
49
|
+
const raw = String(req.url || '');
|
|
50
|
+
const pathname = raw.split('?')[0].split('#')[0];
|
|
51
|
+
const rest = pathname.startsWith(ROUTE_PREFIX)
|
|
52
|
+
? pathname.slice(ROUTE_PREFIX.length)
|
|
53
|
+
: pathname;
|
|
54
|
+
const head = rest.replace(/^\/+/, '').split('/')[0];
|
|
55
|
+
|
|
56
|
+
// 与 index.js 完全一致的两条判定
|
|
57
|
+
const isWriteRoute = (head === 'config' && /\/set\b/.test(pathname)) || head === 'consume';
|
|
58
|
+
const guard = checkLocalOrigin(req, { write: isWriteRoute });
|
|
59
|
+
if (!guard.ok) {
|
|
60
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
61
|
+
res.end(JSON.stringify({ ok: false, reason: guard.reason }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (head === 'config') return configRoute.handler(req, res);
|
|
66
|
+
if (head === 'preview' || head === 'selftest') {
|
|
67
|
+
const g = throttle(head, 3000);
|
|
68
|
+
if (!g.ok) {
|
|
69
|
+
res.writeHead(429, { 'content-type': 'application/json' });
|
|
70
|
+
res.end(JSON.stringify({ ok: false, reason: 'throttled' }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
74
|
+
res.end(JSON.stringify({ ok: true, kind: head }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
return bridge.handler(req, res);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
server.listen(0, '127.0.0.1');
|
|
81
|
+
await once(server, 'listening');
|
|
82
|
+
const { port } = server.address();
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
port,
|
|
86
|
+
base: `http://127.0.0.1:${port}`,
|
|
87
|
+
bridge,
|
|
88
|
+
async close() {
|
|
89
|
+
server.close();
|
|
90
|
+
await once(server, 'close');
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 发一个真实请求(可控 method / headers / body)。 */
|
|
96
|
+
async function call(base, path, { method = 'GET', headers = {}, body } = {}) {
|
|
97
|
+
const res = await fetch(base + ROUTE_PREFIX + path, { method, headers, body });
|
|
98
|
+
const text = await res.text();
|
|
99
|
+
return { status: res.status, text, json: text ? JSON.parse(text) : null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 从 client.js 源码里解析出 consume 的真实调用方式。
|
|
104
|
+
*
|
|
105
|
+
* ## 为什么要这么绕
|
|
106
|
+
*
|
|
107
|
+
* 我第一版 E1 是**手写**一份"看起来对"的请求(POST + JSON)。
|
|
108
|
+
* 但这有个致命弱点(实测暴露):把客户端改回 GET 后,E1 依然全绿 ——
|
|
109
|
+
* 因为它根本没读客户端代码,测的是我自己写的那份。
|
|
110
|
+
*
|
|
111
|
+
* 现在改成**从源码里抠出 method / 是否要求 JSON**,
|
|
112
|
+
* 客户端一改回 GET,这里解析结果就变,E1 立刻变红。
|
|
113
|
+
*/
|
|
114
|
+
function clientConsumeShape() {
|
|
115
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
116
|
+
const src = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
|
|
117
|
+
|
|
118
|
+
// 定位 consume 那一段 fetch(从 apiUrl('consume') 起 300 字符内)
|
|
119
|
+
const at = src.indexOf("apiUrl('consume')");
|
|
120
|
+
assert.ok(at >= 0, 'client.js 里应有 apiUrl(\'consume\')');
|
|
121
|
+
const seg = src.slice(at, at + 400);
|
|
122
|
+
|
|
123
|
+
const method = /method:\s*'([A-Z]+)'/.exec(seg);
|
|
124
|
+
const hasJson = /content-type'\s*:\s*'application\/json'/.test(seg);
|
|
125
|
+
return {
|
|
126
|
+
method: (method ? method[1] : 'GET').toUpperCase(),
|
|
127
|
+
hasJson,
|
|
128
|
+
seg,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── E0: 客户端 consume 必须是 POST + JSON(源码级契约)───────────────────
|
|
133
|
+
test('E0: client.js 里 consume 的调用必须是 POST 且带 JSON content-type', () => {
|
|
134
|
+
const shape = clientConsumeShape();
|
|
135
|
+
assert.equal(shape.method, 'POST',
|
|
136
|
+
'consume 会清除服务端状态,必须用 POST;用 GET 会被 guard 拒绝(实测 403)');
|
|
137
|
+
assert.equal(shape.hasJson, true,
|
|
138
|
+
'必须声明 content-type: application/json —— 否则写操作会被 guard 拒为 write-requires-json');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── E1: 客户端真实调用方式能消费成功(核心回归)──────────────────────────
|
|
142
|
+
test('E1: 按 client.js 的真实调用形状发请求能拿到会话(不是 403)', async () => {
|
|
143
|
+
__resetThrottle();
|
|
144
|
+
const srv = await startServer();
|
|
145
|
+
try {
|
|
146
|
+
srv.bridge.setLast('sess-e1', Date.now());
|
|
147
|
+
|
|
148
|
+
// **用客户端源码里解析出的形状**,而不是手写一份
|
|
149
|
+
const shape = clientConsumeShape();
|
|
150
|
+
const headers = { origin: srv.base };
|
|
151
|
+
if (shape.hasJson) headers['content-type'] = 'application/json';
|
|
152
|
+
const r = await call(srv.base, '/consume', {
|
|
153
|
+
method: shape.method,
|
|
154
|
+
headers,
|
|
155
|
+
body: shape.method === 'POST' ? '{}' : undefined,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
assert.equal(r.status, 200,
|
|
159
|
+
`按客户端形状(${shape.method})应 200,实际 ${r.status}:${r.text}`);
|
|
160
|
+
assert.equal(r.json.sessionId, 'sess-e1', '应拿到刚设置的会话');
|
|
161
|
+
} finally {
|
|
162
|
+
await srv.close();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── E2: 旧的 GET 调用必须被拒(锁住"必须 POST"的契约)──────────────────
|
|
167
|
+
test('E2: GET /consume 被拒绝(405 或 403,但不是 200)', async () => {
|
|
168
|
+
const srv = await startServer();
|
|
169
|
+
try {
|
|
170
|
+
srv.bridge.setLast('sess-e2', Date.now());
|
|
171
|
+
const r = await call(srv.base, '/consume', {
|
|
172
|
+
method: 'GET',
|
|
173
|
+
headers: { origin: srv.base },
|
|
174
|
+
});
|
|
175
|
+
assert.notEqual(r.status, 200,
|
|
176
|
+
'GET 不应消费成功 —— 这个动作会清除服务端状态,必须 POST');
|
|
177
|
+
// 405 是服务端语义拒绝;403 是 guard 拒绝。两者都算正确拦截。
|
|
178
|
+
assert.ok([405, 403].includes(r.status), `应是 405 或 403,实际 ${r.status}`);
|
|
179
|
+
} finally {
|
|
180
|
+
await srv.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── E3: 消费后信号被清(不会重复跳转)──────────────────────────────────
|
|
185
|
+
test('E3: consume 之后 last-session 为空(消费即清)', async () => {
|
|
186
|
+
const srv = await startServer();
|
|
187
|
+
try {
|
|
188
|
+
srv.bridge.setLast('sess-e3', Date.now());
|
|
189
|
+
await call(srv.base, '/consume', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { origin: srv.base, 'content-type': 'application/json' },
|
|
192
|
+
body: '{}',
|
|
193
|
+
});
|
|
194
|
+
const r = await call(srv.base, '/last-session', {
|
|
195
|
+
method: 'GET',
|
|
196
|
+
headers: { origin: srv.base },
|
|
197
|
+
});
|
|
198
|
+
assert.equal(r.status, 204, `消费后应 204,实际 ${r.status}`);
|
|
199
|
+
} finally {
|
|
200
|
+
await srv.close();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── E4: 外部 Origin 写配置被拒(真实请求,不是单测)────────────────────
|
|
205
|
+
test('E4: 外部 Origin 的 config/set 被 403 拒绝', async () => {
|
|
206
|
+
const srv = await startServer();
|
|
207
|
+
try {
|
|
208
|
+
const r = await call(srv.base, '/config/set', {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: { origin: 'https://evil.com', 'content-type': 'application/json' },
|
|
211
|
+
body: JSON.stringify({ values: { volume: 99 } }),
|
|
212
|
+
});
|
|
213
|
+
assert.equal(r.status, 403);
|
|
214
|
+
assert.equal(r.json.reason, 'foreign-origin');
|
|
215
|
+
} finally {
|
|
216
|
+
await srv.close();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── E5: 表单式跨站提交被拒(<form> 发不出 JSON)────────────────────────
|
|
221
|
+
test('E5: 表单 content-type 的 config/set 被拒绝', async () => {
|
|
222
|
+
const srv = await startServer();
|
|
223
|
+
try {
|
|
224
|
+
const r = await call(srv.base, '/config/set', {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: {
|
|
227
|
+
origin: srv.base,
|
|
228
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
229
|
+
},
|
|
230
|
+
body: 'volume=99',
|
|
231
|
+
});
|
|
232
|
+
assert.equal(r.status, 403);
|
|
233
|
+
assert.equal(r.json.reason, 'write-requires-json');
|
|
234
|
+
} finally {
|
|
235
|
+
await srv.close();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── E6: 限流在真实请求下生效 ────────────────────────────────────────────
|
|
240
|
+
test('E6: 连续两次 preview,第二次 429', async () => {
|
|
241
|
+
__resetThrottle();
|
|
242
|
+
const srv = await startServer();
|
|
243
|
+
try {
|
|
244
|
+
const h = { origin: srv.base };
|
|
245
|
+
const a = await call(srv.base, '/preview?kind=done', { headers: h });
|
|
246
|
+
const b = await call(srv.base, '/preview?kind=done', { headers: h });
|
|
247
|
+
assert.equal(a.status, 200, '第一次应放行');
|
|
248
|
+
assert.equal(b.status, 429, '第二次应被限流');
|
|
249
|
+
} finally {
|
|
250
|
+
await srv.close();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── E7: 本测试的分流逻辑必须与 index.js 一致(防单边漂移)───────────────
|
|
255
|
+
test('E7: index.js 的 isWriteRoute 判据与集成测试一致', async () => {
|
|
256
|
+
const { readFileSync } = await import('node:fs');
|
|
257
|
+
const { join, resolve, dirname } = await import('node:path');
|
|
258
|
+
const { fileURLToPath } = await import('node:url');
|
|
259
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
260
|
+
const src = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
|
|
261
|
+
|
|
262
|
+
assert.match(src, /head === 'consume'/,
|
|
263
|
+
"index.js 必须把 consume 当作写路由 —— 与集成测试一致");
|
|
264
|
+
assert.match(src, /checkLocalOrigin\(req, \{ write: isWriteRoute \}\)/,
|
|
265
|
+
'index.js 必须用同一个 guard 调用形状');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── E8: config-route 超限回 413 而不是断连 ──────────────────────────────
|
|
269
|
+
test('E8: 超过 64 KB 的请求体得到 413(连接未被重置)', async () => {
|
|
270
|
+
const srv = await startServer();
|
|
271
|
+
try {
|
|
272
|
+
const big = 'x'.repeat(70 * 1024);
|
|
273
|
+
const r = await call(srv.base, '/config/set', {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: { origin: srv.base, 'content-type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ values: { pad: big } }),
|
|
277
|
+
});
|
|
278
|
+
// 关键:能读到 status 就说明连接没被提前切断
|
|
279
|
+
assert.equal(r.status, 413, `应 413,实际 ${r.status}`);
|
|
280
|
+
assert.equal(r.json.reason, 'body-too-large');
|
|
281
|
+
} finally {
|
|
282
|
+
await srv.close();
|
|
283
|
+
}
|
|
284
|
+
});
|