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