Files
dsh_shenxian/test/plugin-token.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

328 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* **插件装配令牌注入** + **成员查询认实例凭据** 回归测试(2026-09-27 缺陷修复的回归钉)。
*
* ## 这个文件要钉住的两件事(= `交付物/S8落地与回执-20260927.md §④-3/④-4` 的两条缺口)
* 1. 起实例时平台真会注入 `DSH_PLUGIN_TOKENS` / `DSH_PLUGIN_TOKEN_SECRET` / `DSH_INSTANCE_KEY`,
* 且**键与载荷都是完整包名**(签成 `ai1net` ⇒ `token-plugin-mismatch`,现象与没注入一样是 403);
* 2. `GET /api/overlay/my-devices` 那条**只认 cookie** 的闸改成了「会话优先、实例凭据补位」,
* 并**仍然 fail-closed**(无凭据 / 不认的凭据 / 登记簿缺席 ⇒ 401)。
*
* ## 为什么判据写成"独立重算"而不是"调自家 verify 说自己对"
* 令牌形态是**插件侧冻结**的契约(`lib/im-plugin-sdk.mjs` §1,不在本仓)。
* 自证式断言(拿本仓的 verify 验本仓的 sign)**两边一起错也照样绿**。⇒ 这里用手写的
* `createHmac` + NUL 分隔 + 32 字符截断**重算一遍**,并把三条实测踩过的坑各配一条反证。
*
* 运行:`node --test test/plugin-token.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
*
* @module test/plugin-token
*/
import assert from 'node:assert/strict'
import { createHmac, randomBytes } from 'node:crypto'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { test } from 'node:test'
import Fastify from 'fastify'
import { ImInstanceTokenRegistry } from '../lib/im/instance-token.js'
import {
ENV_INSTANCE_KEY,
ENV_PLUGIN_TOKENS,
ENV_PLUGIN_TOKEN_SECRET,
PLUGIN_TOKEN_PREFIX,
claimsOfPluginToken,
instanceKeyOf,
pluginTokenEnv,
pluginTokenEnvForInstance,
signPluginToken,
verifyPluginToken,
} from '../lib/supervisor/plugin-tokens.js'
import { hashSessionToken } from '../lib/web/auth.js'
import { requireAuth, requireAuthOrInstanceToken } from '../lib/web/middleware/authn.js'
/** 被测插件包的**完整包名**(⛔ 不是 `ai1net` —— 那条是坑 ③)。 */
const PKG = '@dsh-local/ai1net'
const SECRET = 'test-secret-not-a-production-key'
const USER = 'u-1001'
const INSTANCE_KEY = 'u:u-1001'
function tmpDir() {
return mkdtempSync(join(tmpdir(), 'dshs-plugin-token-'))
}
/** 独立重算一枚令牌(⛔ 不调被测模块的任何函数)。 */
function independentSign(secret, pluginId, instanceKey, exp) {
const body = Buffer.from(`${pluginId}\u0000${instanceKey}\u0000${Math.trunc(exp)}`, 'utf8').toString('base64url')
const sig = createHmac('sha256', secret).update(body).digest('base64url').slice(0, 32)
return `${PLUGIN_TOKEN_PREFIX}.${body}.${sig}`
}
/* ══════════════ ① 令牌形态:逐字复刻插件侧冻结契约 ══════════════ */
test('① 令牌形态逐字复刻:`p1.<b64u(pluginId\\0instanceKey\\0exp)>.<hmac32>`,独立重算逐字相同', () => {
const exp = Date.now() + 86_400_000
const token = signPluginToken({ secret: SECRET, pluginId: PKG, instanceKey: INSTANCE_KEY, exp })
assert.equal(token, independentSign(SECRET, PKG, INSTANCE_KEY, exp), '令牌必须逐字等于独立重算值')
assert.equal(token.split('.').length, 3)
assert.equal(token.split('.')[0], 'p1')
// 坑 ②:签名段长度恒为 32(取 base64url 前 32 字符,⛔ 不是 hex、⛔ 不是全长)
assert.equal(token.split('.')[2].length, 32)
// 坑 ①:分隔符是 NUL,不是空格 / 点 / 竖线
const decoded = Buffer.from(token.split('.')[1], 'base64url').toString('utf8')
assert.deepEqual(decoded.split('\u0000'), [PKG, INSTANCE_KEY, String(exp)])
// 自家 verify 与独立重算**双向**一致
assert.deepEqual(verifyPluginToken({ secret: SECRET, token, pluginId: PKG, instanceKey: INSTANCE_KEY, now: Date.now() }), {
ok: true,
})
})
test('①-反证:换了密钥 / 篡改载荷 / 过期 ⇒ 各自具名拒绝(⛔ 不静默放行)', () => {
const exp = Date.now() + 86_400_000
const token = signPluginToken({ secret: SECRET, pluginId: PKG, instanceKey: INSTANCE_KEY, exp })
assert.equal(verifyPluginToken({ secret: 'other', token }).reason, 'token-bad-signature')
const [p, body, sig] = token.split('.')
const tampered = Buffer.from(`${PKG}\u0000u:someone-else\u0000${exp}`, 'utf8').toString('base64url')
assert.equal(verifyPluginToken({ secret: SECRET, token: `${p}.${tampered}.${sig}` }).reason, 'token-bad-signature')
assert.equal(verifyPluginToken({ secret: SECRET, token, now: exp + 1 }).reason, 'token-expired')
assert.equal(verifyPluginToken({ secret: SECRET, token: 'not-a-token' }).reason, 'token-malformed')
})
/* ══════════════ ② 键与载荷都必须是完整包名(坑 ③) ══════════════ */
test('② `DSH_PLUGIN_TOKENS` 的**键**是完整包名;令牌载荷里的 pluginId 也是(反证:`ai1net` ⇒ token-plugin-mismatch)', () => {
const env = pluginTokenEnv({ bundles: [PKG], userId: USER, secret: SECRET })
const map = JSON.parse(env[ENV_PLUGIN_TOKENS])
assert.deepEqual(Object.keys(map), [PKG], '键必须是完整包名(候选池主键 / 插件自称的那个值)')
assert.equal(claimsOfPluginToken(map[PKG]).pluginId, PKG)
assert.equal(claimsOfPluginToken(map[PKG]).instanceKey, INSTANCE_KEY)
// 坑 ③ 的反证:签成短名会得到 token-plugin-mismatch —— 现象同样是 403,看着像"包没启用"
const short = signPluginToken({ secret: SECRET, pluginId: 'ai1net', instanceKey: INSTANCE_KEY, exp: Date.now() + 1000 })
assert.equal(
verifyPluginToken({ secret: SECRET, token: short, pluginId: PKG }).reason,
'token-plugin-mismatch',
'⛔ 短名必须被拒 —— 这正是"注入对了也照样 403"的成因',
)
})
test('② `DSH_INSTANCE_KEY` = `u:<userId>`(与 tenantNetworkOf 同口径);换实例 ⇒ token-instance-mismatch', () => {
assert.equal(instanceKeyOf(USER), INSTANCE_KEY)
const env = pluginTokenEnv({ bundles: [PKG], userId: USER, secret: SECRET })
assert.equal(env[ENV_INSTANCE_KEY], INSTANCE_KEY)
assert.equal(env[ENV_PLUGIN_TOKEN_SECRET], SECRET)
const token = JSON.parse(env[ENV_PLUGIN_TOKENS])[PKG]
assert.equal(verifyPluginToken({ secret: SECRET, token, instanceKey: 'u:u-2002' }).reason, 'token-instance-mismatch')
})
test('② 空清单 ⇒ **空对象**(⛔ 不注入半套:只给密钥不给令牌会让人以为"密钥都给过了")', () => {
assert.deepEqual(pluginTokenEnv({ bundles: [], userId: USER, secret: SECRET }), {})
assert.deepEqual(pluginTokenEnv({ bundles: ['', ''], userId: USER, secret: SECRET }), {})
})
/* ══════════════ ③ 注入源 = 本机该用户 profile 的磁盘装配清单 ══════════════ */
test('③ `pluginTokenEnvForInstance` 读的是本机 profile 的 `dsh.profile.bundles`(读不出 ⇒ 空,实例照起)', (t) => {
const dataRoot = tmpDir()
t.after(() => rmSync(dataRoot, { recursive: true, force: true }))
// ① 从未装配过 ⇒ 安静地什么都不注入(插件 fail-closed,⛔ 不阻断实例启动)
assert.deepEqual(pluginTokenEnvForInstance({ dataRoot, userId: USER, secret: SECRET }), {})
// ② 装配过 ⇒ 清单里**每一个包**各一枚
const dir = join(dataRoot, 'users', USER, 'home', 'profiles', 'web')
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name: 'dsh-profile-web', private: true, dsh: { profile: { bundles: [PKG, '@deepseek-ai/dsh-base'] } } }),
)
const env = pluginTokenEnvForInstance({ dataRoot, userId: USER, secret: SECRET })
const map = JSON.parse(env[ENV_PLUGIN_TOKENS])
assert.deepEqual(Object.keys(map).sort(), ['@deepseek-ai/dsh-base', PKG].sort())
assert.equal(env[ENV_INSTANCE_KEY], INSTANCE_KEY)
assert.deepEqual(verifyPluginToken({ secret: SECRET, token: map[PKG], pluginId: PKG, instanceKey: INSTANCE_KEY }), { ok: true })
})
/* ══════════════ ④ 成员查询:改前 401 / 改后 200(同一条路由、同一份凭据) ══════════════ */
function sessionFixture(id, role = 'user') {
const token = randomBytes(8).toString('hex')
return {
token,
cookie: `sid=${token}`,
row: {
expiresAt: Date.now() + 3_600_000,
user: { id, username: id, role, created_at: 1, shared_model_granted: 0 },
},
}
}
/**
* 只挂一条路由的最小 Fastify(`db` 用桩 —— 本步测的是**鉴权闸**,不是数据库)。
* ⚠️ `handler` 与真端点同形:回 `request.user` 让断言能判"认成了谁"。
*/
async function makeApp(preHandler, sessions, registry) {
const app = Fastify()
app.decorate('db', {
findSessionWithUser: async (tokenHash) => sessions.get(tokenHash),
findUserById: async (id) => sessions.get(`user:${id}`),
})
app.decorate('imInstanceTokens', registry)
app.get('/api/overlay/my-devices', { preHandler }, async (request) => ({ user: request.user }))
await app.ready()
return app
}
/** 建一份桩:一个用户会话 + 一枚有效实例凭据(+ 可选的一枚不认的凭据)。 */
function fixtures() {
const s = sessionFixture(USER)
const sessions = new Map([
[hashSessionToken(s.token), s.row],
[`user:${USER}`, s.row.user],
])
const registry = new ImInstanceTokenRegistry()
const { token } = registry.issue({ instanceId: USER, userId: USER, region: '' })
return { s, sessions, registry, instanceToken: token }
}
test('④ 改前(`requireAuth`):仅有实例凭据 ⇒ **401**(这就是实机里包内回 membership-unavailable 的读数)', async (t) => {
const { sessions, registry, instanceToken } = fixtures()
const app = await makeApp(requireAuth, sessions, registry)
t.after(() => app.close())
const res = await app.inject({
method: 'GET',
url: '/api/overlay/my-devices',
headers: { 'x-dsh-im-instance-token': instanceToken, 'x-dsh-im-plugin-id': PKG },
})
assert.equal(res.statusCode, 401, `改前应当 401(实际 ${res.statusCode}:${res.body})`)
})
test('④ 改后(`requireAuthOrInstanceToken`):同一条请求 ⇒ **200**,且认成**实例所属用户**本人', async (t) => {
const { sessions, registry, instanceToken } = fixtures()
const app = await makeApp(requireAuthOrInstanceToken, sessions, registry)
t.after(() => app.close())
const res = await app.inject({
method: 'GET',
url: '/api/overlay/my-devices',
headers: { 'x-dsh-im-instance-token': instanceToken, 'x-dsh-im-plugin-id': PKG },
})
assert.equal(res.statusCode, 200, `改后应当 200(实际 ${res.statusCode}:${res.body})`)
assert.equal(res.json().user.id, USER)
})
test('④ 改后仍 **fail-closed**:无凭据 / 不认的凭据 / 登记簿缺席 / 已停用账号 ⇒ 401(⛔ 绝不跳过校验放行)', async (t) => {
const { sessions, registry, instanceToken } = fixtures()
const app = await makeApp(requireAuthOrInstanceToken, sessions, registry)
t.after(() => app.close())
const none = await app.inject({ method: 'GET', url: '/api/overlay/my-devices' })
assert.equal(none.statusCode, 401)
assert.equal(none.json().error, 'unauthorized')
const bogus = await app.inject({
method: 'GET',
url: '/api/overlay/my-devices',
headers: { 'x-dsh-im-instance-token': randomBytes(32).toString('base64url') },
})
assert.equal(bogus.statusCode, 401)
assert.equal(bogus.json().error, 'instance-token-rejected')
assert.equal(bogus.json().reason, 'unknown')
// 过期凭据(登记簿用可控时钟)
let clock = 1_000
const clocked = new ImInstanceTokenRegistry({ now: () => clock })
const { token: stale } = clocked.issue({ instanceId: USER, userId: USER, ttlMs: 10 })
clock = 2_000
const staleApp = Fastify()
staleApp.decorate('db', { findSessionWithUser: async () => undefined, findUserById: async (id) => sessions.get(`user:${id}`) })
staleApp.decorate('imInstanceTokens', clocked)
staleApp.get('/x', { preHandler: requireAuthOrInstanceToken }, async (request) => ({ user: request.user }))
await staleApp.ready()
t.after(() => staleApp.close())
const expired = await staleApp.inject({ method: 'GET', url: '/x', headers: { 'x-dsh-im-instance-token': stale } })
assert.equal(expired.statusCode, 401)
assert.equal(expired.json().reason, 'expired')
// 登记簿**未接线** ⇒ 凭据一律不认(与 imRoutes 的退化口径一致)
const noRegistry = Fastify()
noRegistry.decorate('db', { findSessionWithUser: async () => undefined, findUserById: async () => undefined })
noRegistry.get('/x', { preHandler: requireAuthOrInstanceToken }, async (request) => ({ user: request.user }))
await noRegistry.ready()
t.after(() => noRegistry.close())
const unwired = await noRegistry.inject({
method: 'GET',
url: '/x',
headers: { 'x-dsh-im-instance-token': instanceToken },
})
assert.equal(unwired.statusCode, 401)
assert.equal(unwired.json().error, 'instance-token-rejected')
// 实例所属账号已停用 ⇒ 不认
const disabled = new ImInstanceTokenRegistry()
const { token: dead } = disabled.issue({ instanceId: USER, userId: USER })
const deadApp = Fastify()
deadApp.decorate('db', {
findSessionWithUser: async () => undefined,
findUserById: async () => ({ ...sessions.get(`user:${USER}`), role: 'disabled' }),
})
deadApp.decorate('imInstanceTokens', disabled)
deadApp.get('/x', { preHandler: requireAuthOrInstanceToken }, async (request) => ({ user: request.user }))
await deadApp.ready()
t.after(() => deadApp.close())
const gone = await deadApp.inject({ method: 'GET', url: '/x', headers: { 'x-dsh-im-instance-token': dead } })
assert.equal(gone.statusCode, 401)
assert.equal(gone.json().error, 'instance-owner-unavailable')
})
test('④ **会话优先**:既带有效 cookie 又带别人的实例凭据 ⇒ 认 cookie 那个人(⛔ 不许降级成实例态)', async (t) => {
const s = sessionFixture(USER)
const other = sessionFixture('u-2002')
const sessions = new Map([
[hashSessionToken(s.token), s.row],
[`user:${USER}`, s.row.user],
[`user:u-2002`, other.row.user],
])
const registry = new ImInstanceTokenRegistry()
const { token: otherToken } = registry.issue({ instanceId: 'u-2002', userId: 'u-2002' })
const app = await makeApp(requireAuthOrInstanceToken, sessions, registry)
t.after(() => app.close())
const res = await app.inject({
method: 'GET',
url: '/api/overlay/my-devices',
headers: { cookie: s.cookie, 'x-dsh-im-instance-token': otherToken },
})
assert.equal(res.statusCode, 200)
assert.equal(res.json().user.id, USER, '有会话时认会话的那个人(用户态比实例态更强)')
})
/* ══════════════ ⑤ 源钉:两个改动点真的落在调用链上 ══════════════ */
test('⑤ 源钉:`GET /api/overlay/my-devices` 走新中间件,且**其余会话路由**仍走 `requireAuth`', () => {
const src = readFileSync(fileURLToPath(new URL('../src/web/routes/sessions.ts', import.meta.url)), 'utf8')
const route = src.split('\n').find((l) => l.includes("app.get('/api/overlay/my-devices'"))
assert.ok(route !== undefined, '必须找得到 my-devices 那条路由(⛔ 找不到 = 断言失效,不是通过)')
assert.match(route, /preHandler:\s*requireAuthOrInstanceToken/, 'my-devices 必须走 requireAuthOrInstanceToken')
// ⚠️ 计数在 2026-09-27(手机接入线 S5)由 3 变 6:新增三条**会话态**路由 ——
// `GET/POST /api/overlay/device-web`(用户自己的开关)+ `POST /api/overlay/my-devices/revoke`
// (停发自己的设备)。判据不变:**它们都必须是 `requireAuth`**(⛔ 不许跟着放宽成实例凭据补位)。
assert.equal(
(src.match(/preHandler:\s*requireAuth\s*[,}]/g) ?? []).length,
6,
'会话 / 单条撤销 / 全部登出 / 读开关 / 写开关 / 停发 —— 这六条⛔ 不许放宽',
)
assert.equal(
(src.match(/preHandler:\s*requireAuthOrInstanceToken/g) ?? []).length,
1,
'⛔ 只有 my-devices 这一条走实例凭据补位',
)
})
test('⑤ 源钉:编排器在 spawn 前组装并注入三项 env(且只对 main)', () => {
const src = readFileSync(fileURLToPath(new URL('../src/supervisor/orchestrator.ts', import.meta.url)), 'utf8')
assert.match(src, /pluginTokenEnvForInstance\(\{/)
assert.match(src, /if \(isMain\) \{[\s\S]{0,400}?pluginTokenEnvForInstance/)
assert.match(src, /baseEnv\(userId, effectiveImToken, pluginTokenVars\)/)
assert.match(src, /\.\.\.pluginTokenVars,/)
})