/** * **插件装配令牌注入** + **成员查询认实例凭据** 回归测试(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..`,独立重算逐字相同', () => { 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:`(与 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,/) })