Files
dsh_shenxian/test/plugin-token.test.mjs
T

327 lines
16 KiB
JavaScript
Raw Normal View History

/**
* **插件装配令牌注入** + **成员查询认实例凭据** 回归测试(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,/)
})