@mzzsfy/dsh-session-manager 0.9.7 → 0.11.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/README.md +2 -2
- package/package.json +9 -3
- package/src/client.js +20 -16
- package/src/index.js +54 -13
- package/test/client-row-actions.test.mjs +20 -0
- package/test/index.test.mjs +59 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 + 归档删除 + 回收站还原重挂载。
|
|
4
4
|
|
|
5
|
-
历史输入浮层、插话撤回与对话分叉已迁至独立插件 `dsh-context-manager`(会话上下文管理);工作区文件夹运行标记见 `dsh-turn-notify
|
|
5
|
+
历史输入浮层、插话撤回与对话分叉已迁至独立插件 `dsh-context-manager`(会话上下文管理);工作区文件夹运行标记见 `dsh-turn-notify`;会话列表瘦身(列表投影裁剪与缓存)已迁至 `dsh-bug-perf`。旧设置项 `listSlimEnabled` 不再读取,开关改由 `dsh-bug-perf` 的 `listSlim.enabled` 管理(默认开启)。本包 0.10.0 及更早版本仍带丢失调用方上下文的旧列表包装,必须升级到 0.11.0(本次发布);旧包装与新 `dsh-bug-perf` 同装时仍会绕过宿主鉴权,不能只升级对端包。
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
@@ -78,7 +78,7 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
|
|
|
78
78
|
npm test
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip,`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
|
|
81
|
+
纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip(3 个非 skip 用例不受影响,其中 Config 导出契约在 peer 缺失时会失败而非 skip),`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
|
|
82
82
|
|
|
83
83
|
## 开发安装(仓库工作副本直挂,不经 npm 发布)
|
|
84
84
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-session-manager",
|
|
3
3
|
"description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.11.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
12
|
"dsh": {
|
|
13
|
+
"hostMin": "0.2.1-rc.2",
|
|
13
14
|
"bundle": {
|
|
14
15
|
"patch": "./cordis.patch.yml"
|
|
15
16
|
},
|
|
@@ -20,12 +21,13 @@
|
|
|
20
21
|
]
|
|
21
22
|
},
|
|
22
23
|
"compatibility": {
|
|
23
|
-
"hostMin": "0.2.1-rc.2",
|
|
24
24
|
"dshReleases": {
|
|
25
25
|
"0.2.0-rc.1": "unknown",
|
|
26
26
|
"0.2.0-rc.2": "unknown",
|
|
27
27
|
"0.2.1-alpha.1": "unknown",
|
|
28
|
-
"0.2.1-rc.2": "compatible"
|
|
28
|
+
"0.2.1-rc.2": "compatible",
|
|
29
|
+
"0.2.1-rc.5": "compatible",
|
|
30
|
+
"0.2.2-rc.1": "compatible"
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
},
|
|
@@ -33,6 +35,10 @@
|
|
|
33
35
|
"test": "node --test \"test/*.test.mjs\"",
|
|
34
36
|
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
35
37
|
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"registry": "https://registry.npmjs.org"
|
|
41
|
+
},
|
|
36
42
|
"license": "MIT",
|
|
37
43
|
"repository": {
|
|
38
44
|
"type": "git",
|
package/src/client.js
CHANGED
|
@@ -136,6 +136,11 @@ const STATUS_URL = '/api/session-manager/status'
|
|
|
136
136
|
// 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
|
|
137
137
|
// 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
|
|
138
138
|
const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
|
|
139
|
+
// 行操作按钮悬停说明:文案与行为同源维护,由源码契约测试锁定
|
|
140
|
+
const RESTORE_BUTTON_TITLE = '把该会话从归档移回会话列表;恢复后 10 分钟内暂停自动归档,保护「恢复 → 首条消息」窗口,期间发言即恢复正常活跃计时。'
|
|
141
|
+
const REMOUNT_BUTTON_TITLE = '把这条已删除记录对应的会话重新挂回列表:产物仍在原位置时直接挂载,产物已进系统回收站时先还原再挂载。'
|
|
142
|
+
const FORGET_BUTTON_TITLE = '只移除这条已删除记录,不动磁盘上的会话产物;用于产物已手动找回、或不再需要追踪的情况。'
|
|
143
|
+
const MORE_BUTTON_TITLE = '展开该工作区剩余的归档会话;只改变面板显示条数,不改变任何会话的归档状态。'
|
|
139
144
|
const DELETE_DIALOG_TITLE = '确认删除该会话?'
|
|
140
145
|
const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
|
|
141
146
|
const DELETE_DIALOG_BODY_SUFFIX = '」。'
|
|
@@ -347,7 +352,7 @@ function ArchiveRow(props) {
|
|
|
347
352
|
]
|
|
348
353
|
: [
|
|
349
354
|
h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
|
|
350
|
-
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
|
|
355
|
+
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, title: RESTORE_BUTTON_TITLE, onClick: props.onUnarchive }, '恢复'),
|
|
351
356
|
]
|
|
352
357
|
return h('div', {
|
|
353
358
|
className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
|
|
@@ -402,8 +407,8 @@ function DeletedRow(props) {
|
|
|
402
407
|
h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
|
|
403
408
|
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
404
409
|
h('div', { className: 'sm-row__actions' },
|
|
405
|
-
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
|
|
406
|
-
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
|
|
410
|
+
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, title: REMOUNT_BUTTON_TITLE, onClick: props.onRemount }, '重新挂载'),
|
|
411
|
+
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, title: FORGET_BUTTON_TITLE, onClick: props.onForget }, '移除记录'),
|
|
407
412
|
),
|
|
408
413
|
h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
|
|
409
414
|
)
|
|
@@ -554,7 +559,7 @@ function SessionManagerApp(props) {
|
|
|
554
559
|
// 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
|
|
555
560
|
const expandMore = (remaining, onMore) => remaining <= 0
|
|
556
561
|
? null
|
|
557
|
-
: h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
562
|
+
: h('button', { key: 'more', className: 'sm-more', title: MORE_BUTTON_TITLE, onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
558
563
|
|
|
559
564
|
// 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
|
|
560
565
|
const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
|
|
@@ -685,29 +690,28 @@ function classifyAutoArchiveInput(committedText, text) {
|
|
|
685
690
|
return { action: 'post', value }
|
|
686
691
|
}
|
|
687
692
|
|
|
693
|
+
// 面板状态:数值项取字符串供受控输入
|
|
694
|
+
const configStateOf = (archive) => ({
|
|
695
|
+
days: String(archive.days),
|
|
696
|
+
intervalHours: String(archive.intervalHours),
|
|
697
|
+
})
|
|
698
|
+
|
|
688
699
|
function AutoArchiveConfig() {
|
|
689
700
|
const [state, setState] = useState(null)
|
|
690
701
|
// 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
|
|
691
702
|
const committedRef = React.useRef(null)
|
|
692
703
|
useEffect(() => {
|
|
693
|
-
|
|
694
|
-
.then((payload) => {
|
|
695
|
-
if (!payload) return
|
|
696
|
-
const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
|
|
697
|
-
committedRef.current = next
|
|
698
|
-
setState(next)
|
|
699
|
-
})
|
|
700
|
-
.catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
|
|
704
|
+
void refresh()
|
|
701
705
|
}, [])
|
|
702
706
|
function refresh() {
|
|
703
707
|
return api(AUTO_ARCHIVE_URL)
|
|
704
|
-
.then((
|
|
705
|
-
if (!
|
|
706
|
-
const next =
|
|
708
|
+
.then((archive) => {
|
|
709
|
+
if (!archive) return
|
|
710
|
+
const next = configStateOf(archive)
|
|
707
711
|
committedRef.current = next
|
|
708
712
|
setState(next)
|
|
709
713
|
})
|
|
710
|
-
.catch((error) => { console.warn('[session-manager]
|
|
714
|
+
.catch((error) => { console.warn('[session-manager] 面板配置回读失败', error) })
|
|
711
715
|
}
|
|
712
716
|
function commit(field, text) {
|
|
713
717
|
const committed = committedRef.current
|
package/src/index.js
CHANGED
|
@@ -160,6 +160,21 @@ function readAutoArchivePatch(body) {
|
|
|
160
160
|
return patch
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// settings 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
|
|
164
|
+
// (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
|
|
165
|
+
// 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
|
|
166
|
+
async function persistSettings(ctx, patch) {
|
|
167
|
+
const settings = ctx.get('settings')
|
|
168
|
+
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
169
|
+
await settings.update(NAMESPACE, patch)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const editor = ctx.get('configEditor')
|
|
173
|
+
const entry = ctx.fiber?.entry
|
|
174
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
|
|
175
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
176
|
+
}
|
|
177
|
+
|
|
163
178
|
function readBody(req) {
|
|
164
179
|
const BODY_MAX_BYTES = 64 * 1024
|
|
165
180
|
return new Promise((resolve, reject) => {
|
|
@@ -215,6 +230,38 @@ function rejectMethod(req, res, method) {
|
|
|
215
230
|
return false
|
|
216
231
|
}
|
|
217
232
|
|
|
233
|
+
// 写路由守卫(全仓同构):浏览器跨站表单可发 text/plain 且载荷为合法 JSON,
|
|
234
|
+
// 仅方法守卫不足以阻断 drive-by 写,补同源比对与 JSON 内容类型双校验。
|
|
235
|
+
// 无 Origin 的非浏览器调用放行(本地 CLI/脚本);无 headers 的直调桩同样放行。
|
|
236
|
+
function rejectCrossOrigin(req, res) {
|
|
237
|
+
const origin = req.headers ? req.headers.origin : undefined
|
|
238
|
+
if (!origin) return false
|
|
239
|
+
let sameOrigin = false
|
|
240
|
+
try {
|
|
241
|
+
sameOrigin = new URL(origin).host === req.headers.host
|
|
242
|
+
} catch {
|
|
243
|
+
sameOrigin = false
|
|
244
|
+
}
|
|
245
|
+
if (sameOrigin) return false
|
|
246
|
+
sendJson(res, 403, { error: '跨源请求被拒绝' })
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 内容类型守卫:浏览器发起的跨站请求(表单/fetch 字符串体)必带 text/plain 等类型,
|
|
251
|
+
// 借此阻断 drive-by 写;完全不带 content-type 的调用(本地 CLI/无头脚本)放行,
|
|
252
|
+
// 与"无 Origin 放行"同口径
|
|
253
|
+
function rejectNonJson(req, res) {
|
|
254
|
+
const type = req.headers ? req.headers['content-type'] : undefined
|
|
255
|
+
if (typeof type !== 'string' || type.trim() === '') return false
|
|
256
|
+
if (type.split(';')[0].trim().toLowerCase() === 'application/json') return false
|
|
257
|
+
sendJson(res, 415, { error: '仅接受 application/json 请求体' })
|
|
258
|
+
return true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function guardWrite(req, res) {
|
|
262
|
+
return rejectCrossOrigin(req, res) || rejectNonJson(req, res)
|
|
263
|
+
}
|
|
264
|
+
|
|
218
265
|
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
219
266
|
export function apply(ctx, config) {
|
|
220
267
|
let evaluating = false
|
|
@@ -445,6 +492,7 @@ export function apply(ctx, config) {
|
|
|
445
492
|
path: '/api/session-manager/unarchive',
|
|
446
493
|
handler: async (req, res) => {
|
|
447
494
|
if (!rejectMethod(req, res, 'POST')) return
|
|
495
|
+
if (guardWrite(req, res)) return
|
|
448
496
|
try {
|
|
449
497
|
const sessionId = await requireSessionId(req)
|
|
450
498
|
await removeArchivedId(sessionId)
|
|
@@ -459,6 +507,7 @@ export function apply(ctx, config) {
|
|
|
459
507
|
path: '/api/session-manager/info',
|
|
460
508
|
handler: async (req, res) => {
|
|
461
509
|
if (!rejectMethod(req, res, 'POST')) return
|
|
510
|
+
if (guardWrite(req, res)) return
|
|
462
511
|
try {
|
|
463
512
|
const sessionId = await requireSessionId(req)
|
|
464
513
|
const header = await findHeader(ctx, sessionId, headerCache)
|
|
@@ -493,6 +542,7 @@ export function apply(ctx, config) {
|
|
|
493
542
|
path: '/api/session-manager/delete',
|
|
494
543
|
handler: async (req, res) => {
|
|
495
544
|
if (!rejectMethod(req, res, 'POST')) return
|
|
545
|
+
if (guardWrite(req, res)) return
|
|
496
546
|
const sessionId = await requireSessionId(req).catch((error) => {
|
|
497
547
|
respondError(ctx, res, error)
|
|
498
548
|
return undefined
|
|
@@ -644,6 +694,7 @@ export function apply(ctx, config) {
|
|
|
644
694
|
return
|
|
645
695
|
}
|
|
646
696
|
if (!rejectMethod(req, res, 'POST')) return
|
|
697
|
+
if (guardWrite(req, res)) return
|
|
647
698
|
let body
|
|
648
699
|
try {
|
|
649
700
|
body = JSON.parse(await readBody(req))
|
|
@@ -651,19 +702,7 @@ export function apply(ctx, config) {
|
|
|
651
702
|
throw new Error(MESSAGES.badJsonBody)
|
|
652
703
|
}
|
|
653
704
|
const patch = readAutoArchivePatch(body)
|
|
654
|
-
|
|
655
|
-
// (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
|
|
656
|
-
// 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
|
|
657
|
-
// await 保证回显为新值,persist 失败走 respondError
|
|
658
|
-
const settings = ctx.get('settings')
|
|
659
|
-
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
660
|
-
await settings.update(NAMESPACE, patch)
|
|
661
|
-
} else {
|
|
662
|
-
const editor = ctx.get('configEditor')
|
|
663
|
-
const entry = ctx.fiber?.entry
|
|
664
|
-
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
|
|
665
|
-
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
666
|
-
}
|
|
705
|
+
await persistSettings(ctx, patch)
|
|
667
706
|
const current = readSettings(ctx, config)
|
|
668
707
|
sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
|
|
669
708
|
} catch (error) {
|
|
@@ -687,6 +726,7 @@ export function apply(ctx, config) {
|
|
|
687
726
|
path: '/api/session-manager/remount',
|
|
688
727
|
handler: async (req, res) => {
|
|
689
728
|
if (!rejectMethod(req, res, 'POST')) return
|
|
729
|
+
if (guardWrite(req, res)) return
|
|
690
730
|
try {
|
|
691
731
|
const sessionId = await requireSessionId(req)
|
|
692
732
|
// 移回依据是台账条目,台账域不可用时整个重挂载拒绝:
|
|
@@ -730,6 +770,7 @@ export function apply(ctx, config) {
|
|
|
730
770
|
path: '/api/session-manager/forget',
|
|
731
771
|
handler: async (req, res) => {
|
|
732
772
|
if (!rejectMethod(req, res, 'POST')) return
|
|
773
|
+
if (guardWrite(req, res)) return
|
|
733
774
|
try {
|
|
734
775
|
await removeLedgerEntry(await requireSessionId(req))
|
|
735
776
|
sendJson(res, 200, { ok: true })
|
|
@@ -44,3 +44,23 @@ test('源码契约:已删除区保持悬停显现且减弱动效覆盖新选择
|
|
|
44
44
|
assert.ok(/prefers-reduced-motion: reduce.*\{ \.sm-btn--danger, \.sm-deleted \.sm-row__actions \{ transition:none/.test(CLIENT_SRC),
|
|
45
45
|
'减弱动效未覆盖删除按钮与已删除区过渡')
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
// Given 归档行与已删除行的操作按钮, When 渲染, Then 每个按钮都带 title 悬停说明
|
|
49
|
+
test('源码契约:行操作按钮都带 title 悬停说明', () => {
|
|
50
|
+
const buttons = [
|
|
51
|
+
['restore', 'RESTORE_BUTTON_TITLE'],
|
|
52
|
+
['remount', 'REMOUNT_BUTTON_TITLE'],
|
|
53
|
+
['forget', 'FORGET_BUTTON_TITLE'],
|
|
54
|
+
['delete', 'DELETE_BUTTON_TITLE'],
|
|
55
|
+
['more', 'MORE_BUTTON_TITLE'],
|
|
56
|
+
]
|
|
57
|
+
for (const [key, constant] of buttons) {
|
|
58
|
+
const pattern = new RegExp("key: '" + key + "'[^\\n]*title: " + constant)
|
|
59
|
+
assert.ok(pattern.test(CLIENT_SRC), key + ' 按钮缺少 title(' + constant + ')')
|
|
60
|
+
}
|
|
61
|
+
for (const constant of ['RESTORE_BUTTON_TITLE', 'REMOUNT_BUTTON_TITLE', 'FORGET_BUTTON_TITLE', 'MORE_BUTTON_TITLE']) {
|
|
62
|
+
const matched = CLIENT_SRC.match(new RegExp('const ' + constant + " = '([^']+)'"))
|
|
63
|
+
assert.ok(matched, constant + ' 未定义')
|
|
64
|
+
assert.ok(matched[1].length >= 20, constant + ' 文案须讲清用途')
|
|
65
|
+
}
|
|
66
|
+
})
|
package/test/index.test.mjs
CHANGED
|
@@ -1888,3 +1888,62 @@ test('0.1.7 形态:settings.configure 在场即关闭原生自动页', skipMissi
|
|
|
1888
1888
|
assert.equal(configureCalls.length, 1)
|
|
1889
1889
|
assert.deepEqual(configureCalls[0].presentation, { auto: false })
|
|
1890
1890
|
})
|
|
1891
|
+
|
|
1892
|
+
// ── 写路由同源 + JSON 守卫回归 ──
|
|
1893
|
+
|
|
1894
|
+
// POST 夹具:带 headers 的请求体走 readBody 的 data/end 事件流
|
|
1895
|
+
function guardedRequest(routePath, headers, body = { sessionId: 's1' }) {
|
|
1896
|
+
const req = new EventEmitter()
|
|
1897
|
+
req.method = 'POST'
|
|
1898
|
+
req.url = routePath
|
|
1899
|
+
req.headers = headers
|
|
1900
|
+
process.nextTick(() => {
|
|
1901
|
+
req.emit('data', Buffer.from(JSON.stringify(body)))
|
|
1902
|
+
req.emit('end')
|
|
1903
|
+
})
|
|
1904
|
+
return req
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
test('写路由守卫:跨源 POST 403 / 非 JSON 内容类型 415 / 同源 JSON 正常受理', skipMissingDeps, async () => {
|
|
1908
|
+
// Given 全部写路由(浏览器表单可发 text/plain 且载荷为合法 JSON,仅方法守卫不足)
|
|
1909
|
+
const POST_ROUTES = [
|
|
1910
|
+
'/api/session-manager/unarchive',
|
|
1911
|
+
'/api/session-manager/info',
|
|
1912
|
+
'/api/session-manager/delete',
|
|
1913
|
+
'/api/session-manager/auto-archive',
|
|
1914
|
+
'/api/session-manager/remount',
|
|
1915
|
+
'/api/session-manager/forget',
|
|
1916
|
+
]
|
|
1917
|
+
const ORIGIN = 'http://127.0.0.1:9291'
|
|
1918
|
+
const sameOrigin = {
|
|
1919
|
+
origin: ORIGIN,
|
|
1920
|
+
host: '127.0.0.1:9291',
|
|
1921
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
for (const routePath of POST_ROUTES) {
|
|
1925
|
+
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
|
|
1926
|
+
|
|
1927
|
+
// When Origin 与 Host 不同 Then 403,且响应为统一拒绝文案
|
|
1928
|
+
const crossOrigin = response()
|
|
1929
|
+
await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, origin: 'http://evil.example' }), crossOrigin)
|
|
1930
|
+
assert.equal(crossOrigin.status, 403, `${routePath} 跨源 POST 必须 403`)
|
|
1931
|
+
|
|
1932
|
+
// When 同源但 content-type: text/plain Then 415(drive-by 表单形态)
|
|
1933
|
+
const textPlain = response()
|
|
1934
|
+
await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, 'content-type': 'text/plain' }), textPlain)
|
|
1935
|
+
assert.equal(textPlain.status, 415, `${routePath} text/plain POST 必须 415`)
|
|
1936
|
+
|
|
1937
|
+
// When 无 Origin(本地 CLI)但带 text/plain Then 仍 415(内容类型守卫独立生效)
|
|
1938
|
+
const cliTextPlain = response()
|
|
1939
|
+
await handlers.get(routePath)(guardedRequest(routePath, { host: '127.0.0.1:9291', 'content-type': 'text/plain' }), cliTextPlain)
|
|
1940
|
+
assert.equal(cliTextPlain.status, 415, `${routePath} 无 Origin 的 text/plain 仍必须 415`)
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// When 同源 application/json Then 正常受理(unarchive 可幂等成功)
|
|
1944
|
+
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
|
|
1945
|
+
const accepted = response()
|
|
1946
|
+
await handlers.get('/api/session-manager/unarchive')(guardedRequest('/api/session-manager/unarchive', sameOrigin), accepted)
|
|
1947
|
+
assert.equal(accepted.status, 200)
|
|
1948
|
+
assert.deepEqual(accepted.body, { ok: true })
|
|
1949
|
+
})
|