/** * S9 多租户管理面 · **鉴权口径**单测(2026-09-27 · 平台侧执行棒)。 * * 运行:`node --test test/tenants-auth.test.mjs`(⛔ 不连真库、⛔ 不起真进程)。 * * ## 本文件测的是什么(= 本轮改动的验收面) * 四条端点(`GET /api/admin/tenants` · `GET /api/admin/tenants/:id` · * `POST …/instance/ensure` · `POST …/instance/reclaim`)由 `requireAdmin` 换成 * `requireAdminOrInstanceToken`:**会话或实例凭据都认,但权限一寸不放宽**。 * * | # | 用例 | 判据 | * |---|---|---| * | ① | 管理员实例凭据 | 四条全 **200**(插件包那条路真的通了) | * | ② | **非管理员**实例凭据 | 四条全 **403**,错误名具名 `tenant-admin-forbidden`,且⛔ **动作未被触达** | * | ③ | 无凭据 / 凭据不认(未签发 · 过期 · 跨区 · 登记簿未接线 · 属主不存在/停用) | 全 **401 fail-closed**,拒绝体与既有形态同源 | * | ④ | 会话路径 | 与改前 `requireAdmin` **逐字一致**(admin 200 / 非 admin 403 `forbidden` / 无或失效会话 401) | * | ⑤ | 会话优先不降级 | 有效非 admin 会话 + 管理员实例凭据 ⇒ 仍 403 `forbidden`(⛔ 不许"换个身份再来");失效会话 + 管理员实例凭据 ⇒ 200(补位) | * | ⑥ | 旧用例不回退 | 由 `test/tenants.test.mjs` 的 10 条守住(本文件不重复那十条) | * * ## 口径提醒 * - 实例凭据用**真的** `ImInstanceTokenRegistry`(不是手搓桩)—— 否则测不到"签发 → 校验 → 所属用户"这条链; * - 会话解析仍走桩(会话层不是本文件的被测对象); * - ⛔ 本文件不建表、不写库、不 spawn 进程。 */ import { test } from 'node:test' import assert from 'node:assert/strict' import Fastify from 'fastify' import { openPgAdapter } from '../lib/db/pg.js' import { ImInstanceTokenRegistry } from '../lib/im/instance-token.js' import { tenantRoutes } from '../lib/web/routes/tenants.js' import { PG_SKIP_REASON, ensureMigrated, resetPg } from './pg-test-db.mjs' /** * **真库用例**(⑥)的连接串:统一口径 `DSHS_TEST_DB_URL`(旧名 `LEASETEST_PG_URL` 兜底)。 * ⚠️ 必须指向**专用测试库** —— `resetPg` 会 TRUNCATE 该库 `public` 下全部表。 * ⛔ 未设 ⇒ 只跳⑥这一条(本文件前 7 条是纯桩用例,照跑)。 */ const PG_URL = process.env.DSHS_TEST_DB_URL ?? process.env.LEASETEST_PG_URL const PG_SKIP = PG_URL === undefined || PG_URL === '' ? PG_SKIP_REASON : false /** 真库用例专用入口(缺连接串 ⇒ 显式 skip,⛔ 不静默通过)。 */ const pgTest = (name, fn) => test(name, { skip: PG_SKIP }, fn) if (PG_SKIP === false) { // 迁移先跑一次 ⇒ 后面 TRUNCATE / 建用户才有表(口径同 `tenant-members.test.mjs`)。 await ensureMigrated(PG_URL) } else { console.log(`# 真库用例(S9-鉴权⑥)未执行:${PG_SKIP_REASON}`) } /* ─────────────────────────── 桩 ─────────────────────────── */ // ⚠️ 两个形状都要给:列表走 `listPublicUsers()`(`PublicUser`:`createdAt`), // 详情走 `findUserById()`(整行 `User`:`created_at`)。 const ADMIN_ROW = { id: 'u-admin', username: 'admin', role: 'admin', createdAt: 1_700_000_000_000, created_at: 1_700_000_000_000, sharedModelGranted: false, } const USER_ROW = { id: 'u-1', username: 'acme', role: 'active', createdAt: 1_700_000_000_000, created_at: 1_700_000_000_000, sharedModelGranted: false, } const DISABLED_ROW = { id: 'u-off', username: 'off', role: 'disabled', createdAt: 1_700_000_000_000, created_at: 1_700_000_000_000, sharedModelGranted: false, } const USERS = [ADMIN_ROW, USER_ROW, DISABLED_ROW] /** 四条端点(鉴权判据对四条必须一致 —— 少测一条就是漏判据面)。 */ const ENDPOINTS = [ ['GET', '/api/admin/tenants'], ['GET', '/api/admin/tenants/u-1'], ['POST', '/api/admin/tenants/u-1/instance/ensure'], ['POST', '/api/admin/tenants/u-1/instance/reclaim'], ] /** 会话桩:**不解析 cookie**,按当轮设定回一行(`valid=false` ⇒ 视为 cookie 失效)。 */ function sessionRow(user) { return user === null ? undefined : { user, expiresAt: Date.now() + 3_600_000 } } /** * 起一个**只挂 tenants 路由**的 Fastify,全部外部依赖走桩。 * * `opts`: * - `sessionUser` 当轮会话被认成谁(`null` / `undefined` = 无会话) * - `registry` `app.imInstanceTokens`(`undefined` = 未接线 ⇒ 任何实例凭据都必须 401) * - `users` `findUserById` / `listPublicUsers` 的数据源 * - `quotaInfo` 配额观测面桩(`undefined` = 本部署没有该面) * - `launchThrows` / `isDirectory` / `instances` 同 `tenants.test.mjs`,供动作类端点用 */ async function buildApp(opts = {}) { const calls = { launch: [], stop: [] } const instances = opts.instances ?? new Map() const users = opts.users ?? USERS let current = opts.sessionUser === undefined ? USER_ROW : opts.sessionUser let sessionValid = true const app = Fastify() app.decorate('db', { findSessionWithUser: async () => (sessionValid ? sessionRow(current) : undefined), listPublicUsers: async () => users, findUserById: async (id) => users.find((u) => u.id === id), }) app.decorate('config', { baseDomain: 'example.test', enablePatch: false }) app.decorate('userFs', { resolvePath: (userId, folder) => `/data/users/${userId}/ws/${folder}`, isDirectory: async () => opts.isDirectory ?? true, listInstalledPlugins: async () => [], }) app.decorate('supervisor', { status: async (userId) => instances.get(userId) ?? { main: undefined, watchdog: undefined }, breakerInfo: () => null, ...(opts.quotaInfo === undefined ? {} : { quotaInfo: () => opts.quotaInfo }), launch: async (userId, folder, patch) => { calls.launch.push({ userId, folder, patch }) if (opts.launchThrows !== undefined) throw opts.launchThrows return { id: 'inst-1', port: 20001, status: 'starting', launchToken: 'tok-1' } }, stop: async (userId) => { calls.stop.push(userId) instances.delete(userId) }, }) // ⚠️ 未接线时**不 decorate**(= 真实 server.ts 之外的退化形态)⇒ 必须 401。 if (opts.registry !== undefined) app.decorate('imInstanceTokens', opts.registry) await app.register(tenantRoutes) await app.ready() return { app, calls, /** 只发 cookie、⛔ 不发实例凭据(会话路径专用)。 */ cookieOnly: { cookie: 'sid=some-session' }, /** `as(用户, 会话是否有效)` —— `as(null)` = 无会话;`as(u, false)` = 有 cookie 但会话失效。 */ as: (u, valid = true) => { current = u sessionValid = valid }, } } /** 一条实例凭据头。 */ function tokenHeader(token) { return { 'x-dsh-im-instance-token': token } } /* ─────────────────── ① 管理员实例凭据 ⇒ 放行 ─────────────────── */ test('S9-鉴权① 管理员实例凭据 ⇒ 四条端点全 200(插件包那条路真的通了)', async (t) => { const registry = new ImInstanceTokenRegistry() const { token } = registry.issue({ instanceId: 'inst-admin', userId: 'u-admin' }) const { app, as } = await buildApp({ registry, quotaInfo: { baseMb: 640, memMb: 2048, heapMb: 300 }, }) t.after(() => app.close()) as(null) // 🔴 关键:**没有浏览器会话**(插件包的真实处境) for (const [method, url] of ENDPOINTS) { const res = await app.inject({ method, url, headers: tokenHeader(token), payload: {} }) assert.equal(res.statusCode, 200, `${method} ${url} 管理员实例凭据应当 200(实际 ${res.statusCode}:${res.body})`) } // 凭据本身⛔ 不许回显在响应里 const list = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(token) }) assert.equal(list.body.includes(token), false, '响应体⛔ 不许带出实例凭据原文') assert.equal(list.json().total, 3, '管理面语义不变:还是"全部租户"') }) /* ─────────────────── ② 非管理员实例凭据 ⇒ 403 具名 ─────────────────── */ test('S9-鉴权② 非管理员实例凭据 ⇒ 403 具名 tenant-admin-forbidden,且⛔ 动作未被触达', async (t) => { const registry = new ImInstanceTokenRegistry() const { token } = registry.issue({ instanceId: 'inst-user', userId: 'u-1' }) // u-1 的 role = active const { app, calls, as } = await buildApp({ registry, quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 }, }) t.after(() => app.close()) as(null) for (const [method, url] of ENDPOINTS) { const res = await app.inject({ method, url, headers: tokenHeader(token), payload: {} }) assert.equal(res.statusCode, 403, `${method} ${url} 非管理员实例凭据应当 403(实际 ${res.statusCode}:${res.body})`) const body = res.json() // 🔴 具名拒 —— 与"会话非管理员"的 `forbidden` **区分开**(判据面才可断言) assert.equal(body.error, 'tenant-admin-forbidden', `${method} ${url} 必须具名 tenant-admin-forbidden`) assert.equal(body.reason, 'not_admin') // ⛔ 权限不降级:拒绝体里不许出现任何"放行"语义 assert.equal('ok' in body, false) } // 🔴 最关键的一条:拒绝发生在**动作之前**(否则"拒了但实例已被回收/拉起"就是事故) assert.deepEqual(calls.launch, [], '非管理员实例凭据⛔ 不许走到 supervisor.launch') assert.deepEqual(calls.stop, [], '非管理员实例凭据⛔ 不许走到 supervisor.stop') }) /* ─────────────────── ③ 无凭据 / 凭据不认 ⇒ 401 fail-closed ─────────────────── */ test('S9-鉴权③ 无凭据 ⇒ 401(fail-closed 不变)', async (t) => { const registry = new ImInstanceTokenRegistry() const { app, as } = await buildApp({ registry }) t.after(() => app.close()) as(null) for (const [method, url] of ENDPOINTS) { const res = await app.inject({ method, url, payload: {} }) assert.equal(res.statusCode, 401, `${method} ${url} 无凭据应当 401(实际 ${res.statusCode})`) assert.equal(res.json().error, 'unauthorized') } }) test('S9-鉴权③ 实例凭据不认:未签发 / 过期 / 跨区 / 登记簿未接线 ⇒ 全 401 且具名', async (t) => { // ── 未签发(外形合法但登记簿里没有)──────────────────────────────── const registry = new ImInstanceTokenRegistry() const fresh = await buildApp({ registry }) t.after(() => fresh.app.close()) fresh.as(null) const unknown = await fresh.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader('A'.repeat(43)), }) assert.equal(unknown.statusCode, 401) assert.equal(unknown.json().error, 'instance-token-rejected') assert.equal(unknown.json().reason, 'unknown') // ── 登记簿未接线(`app.imInstanceTokens === undefined`)───────────── const wired = new ImInstanceTokenRegistry() const { token: wiredToken } = wired.issue({ instanceId: 'inst-a', userId: 'u-admin' }) const unwired = await buildApp({ registry: undefined }) t.after(() => unwired.app.close()) unwired.as(null) const r = await unwired.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(wiredToken), }) assert.equal(r.statusCode, 401, '登记簿未接线 ⇒ 必须视为"凭据不认",⛔ 不放行') assert.equal(r.json().error, 'instance-token-rejected') // ── 过期 ──────────────────────────────────────────────────────────── const clock = { t: 1_000_000 } const expiring = new ImInstanceTokenRegistry({ now: () => clock.t }) const { token: staleToken } = expiring.issue({ instanceId: 'inst-e', userId: 'u-admin', ttlMs: 1_000 }) const stale = await buildApp({ registry: expiring }) t.after(() => stale.app.close()) stale.as(null) clock.t += 2_000 const expired = await stale.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(staleToken), }) assert.equal(expired.statusCode, 401) assert.equal(expired.json().reason, 'expired') // ── 跨区(只在请求**显式声明**区时触发)──────────────────────────── const regioned = new ImInstanceTokenRegistry() const { token: regionToken } = regioned.issue({ instanceId: 'inst-r', userId: 'u-admin', region: 'r1' }) const byRegion = await buildApp({ registry: regioned }) t.after(() => byRegion.app.close()) byRegion.as(null) const mismatch = await byRegion.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: { ...tokenHeader(regionToken), 'x-dsh-im-region': 'r2' }, }) assert.equal(mismatch.statusCode, 401) assert.equal(mismatch.json().reason, 'region_mismatch') // 不声明区 ⇒ 不触发区判据(与本仓既有口径一致) const noDeclare = await byRegion.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(regionToken), }) assert.equal(noDeclare.statusCode, 200, '未声明区 ⇒ 区判据不触发(管理员凭据仍放行)') }) test('S9-鉴权③ 属主不存在 / 已停用 ⇒ 401(⛔ 不因"凭据本身有效"就放行)', async (t) => { const registry = new ImInstanceTokenRegistry() const { token: ghostToken } = registry.issue({ instanceId: 'inst-g', userId: 'u-ghost' }) const { token: offToken } = registry.issue({ instanceId: 'inst-o', userId: 'u-off' }) const { app, as } = await buildApp({ registry }) t.after(() => app.close()) as(null) for (const [token, why] of [ [ghostToken, '属主不存在'], [offToken, '属主已停用'], ]) { const res = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(token) }) assert.equal(res.statusCode, 401, `${why} ⇒ 应当 401(实际 ${res.statusCode})`) assert.equal(res.json().error, 'instance-owner-unavailable') } }) /* ─────────────────── ④ 会话路径:与改前逐字一致 ─────────────────── */ test('S9-鉴权④ 会话路径行为与改前 requireAdmin 逐字一致', async (t) => { const registry = new ImInstanceTokenRegistry() const { app, as } = await buildApp({ registry, quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 }, }) t.after(() => app.close()) // 管理员会话 ⇒ 200 as(ADMIN_ROW) for (const [method, url] of ENDPOINTS) { const res = await app.inject({ method, url, headers: { cookie: 'sid=admin' }, payload: {} }) assert.equal(res.statusCode, 200, `${method} ${url} 管理员会话应当 200(实际 ${res.statusCode}:${res.body})`) } // 非管理员会话 ⇒ 403 **`forbidden`**(逐字:改前就是这个体,⛔ 不许变成新名) as(USER_ROW) for (const [method, url] of ENDPOINTS) { const res = await app.inject({ method, url, headers: { cookie: 'sid=user' }, payload: {} }) assert.equal(res.statusCode, 403) assert.deepEqual(res.json(), { error: 'forbidden' }, `${method} ${url} 会话非管理员的拒绝体必须与改前逐字相同`) } // 无会话 ⇒ 401 as(null) for (const [method, url] of ENDPOINTS) { const res = await app.inject({ method, url, payload: {} }) assert.equal(res.statusCode, 401) assert.deepEqual(res.json(), { error: 'unauthorized' }) } // 有 cookie 但会话失效(查不到行)⇒ 401(与改前一致) as(USER_ROW, false) for (const [method, url] of ENDPOINTS) { const res = await app.inject({ method, url, headers: { cookie: 'sid=stale' }, payload: {} }) assert.equal(res.statusCode, 401, `${method} ${url} 失效会话应当 401`) } }) /* ─────────────────── ⑤ 会话优先:⛔ 不许"换个身份再来" ─────────────────── */ test('S9-鉴权⑤ 会话优先不降级:非管理员会话 + 管理员实例凭据 ⇒ 仍 403 forbidden', async (t) => { const registry = new ImInstanceTokenRegistry() const { token: adminToken } = registry.issue({ instanceId: 'inst-admin', userId: 'u-admin' }) const { app, as } = await buildApp({ registry, quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 }, }) t.after(() => app.close()) // 有效会话 = 非管理员,但同一请求又带了"管理员的实例凭据" ⇒ ⛔ 不许认后者 as(USER_ROW) const res = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: { cookie: 'sid=user', ...tokenHeader(adminToken) }, }) assert.equal(res.statusCode, 403, `会话有效即定论,⛔ 不许换成实例身份再来(实际 ${res.statusCode}:${res.body})`) assert.deepEqual(res.json(), { error: 'forbidden' }) // 反向:管理员会话 + **非管理员**实例凭据 ⇒ 会话说了算,仍然是 200(⛔ 不因 token 差而降级) const other = new ImInstanceTokenRegistry() const { token: userToken } = other.issue({ instanceId: 'inst-user', userId: 'u-1' }) const mixed = await buildApp({ registry: other, quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 } }) t.after(() => mixed.app.close()) mixed.as(ADMIN_ROW) const ok = await mixed.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: { cookie: 'sid=admin', ...tokenHeader(userToken) }, }) assert.equal(ok.statusCode, 200, `管理员会话应当 200(实际 ${ok.statusCode}:${ok.body})`) // 补位语义:会话**失效** + 管理员实例凭据 ⇒ 200(这正是本轮要开的那条路) const both = new ImInstanceTokenRegistry() const { token: bothAdmin } = both.issue({ instanceId: 'inst-admin-2', userId: 'u-admin' }) const { token: bothUser } = both.issue({ instanceId: 'inst-user-2', userId: 'u-1' }) const fallback = await buildApp({ registry: both, quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 } }) t.after(() => fallback.app.close()) fallback.as(ADMIN_ROW, false) // 带 cookie,但会话已失效 const okAdmin = await fallback.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: { cookie: 'sid=stale', ...tokenHeader(bothAdmin) }, }) assert.equal(okAdmin.statusCode, 200, `失效会话 + 管理员实例凭据 ⇒ 应当 200(实际 ${okAdmin.statusCode}:${okAdmin.body})`) fallback.as(USER_ROW, false) const denyUser = await fallback.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: { cookie: 'sid=stale', ...tokenHeader(bothUser) }, }) assert.equal(denyUser.statusCode, 403, '失效会话 + 非管理员实例凭据 ⇒ 仍须 403(fail-closed 不放宽)') assert.equal(denyUser.json().error, 'tenant-admin-forbidden') }) /* ─────────────────── ⑥ 真库:role 判据打在真 users 行上 ─────────────────── */ test('S9-鉴权⑥ 真库:实例凭据 → 真 users 行 → role 判据(admin 放行 / 非 admin 403 / 停用 401 / 无凭据 401)', { skip: PG_SKIP }, async (t) => { await resetPg(PG_URL) const db = await openPgAdapter(PG_URL, 100000) t.after(() => db.close()) for (const [id, username, role] of [ ['u-ra', 'real-admin', 'admin'], ['u-ru', 'real-user', 'active'], ['u-ro', 'real-off', 'disabled'], ]) { await db.createUser({ id, username, passHash: 'x', role, homeDir: `/tmp/${id}` }) } const registry = new ImInstanceTokenRegistry() const tok = (instanceId, userId) => registry.issue({ instanceId, userId }).token const adminTok = tok('i-ra', 'u-ra') const userTok = tok('i-ru', 'u-ru') const offTok = tok('i-ro', 'u-ro') const app = Fastify() app.decorate('db', db) // 🔴 **真适配器**(真 SQL、真 users 行),不是桩 app.decorate('config', { baseDomain: 'example.test', enablePatch: false }) app.decorate('userFs', { resolvePath: (userId, folder) => `/data/users/${userId}/ws/${folder}`, isDirectory: async () => true, listInstalledPlugins: async () => [], }) app.decorate('supervisor', { status: async () => ({ main: undefined, watchdog: undefined }), breakerInfo: () => null, }) app.decorate('imInstanceTokens', registry) await app.register(tenantRoutes) await app.ready() t.after(() => app.close()) // ① 真库里的管理员 ⇒ 放行(且租户列表来自真 `listPublicUsers()`) const ok = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(adminTok) }) assert.equal(ok.statusCode, 200, `真库管理员实例凭据应当 200(实际 ${ok.statusCode}:${ok.body})`) assert.ok( ok.json().tenants.some((x) => x.id === 'u-ra'), '列表必须来自真库 `listPublicUsers()`(含真 admin 行)', ) // ② 真库里的普通用户 ⇒ 403 具名(🔴 本棒红线:⛔ 不降低权限) const denied = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(userTok) }) assert.equal(denied.statusCode, 403, `真库非管理员实例凭据必须 403(实际 ${denied.statusCode}:${denied.body})`) assert.equal(denied.json().error, 'tenant-admin-forbidden') // ③ 真库里已停用的管理员?先确认 stop 语义:停用账号的凭据一律 401(⛔ 不因"曾是 admin"放行) const off = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader(offTok) }) assert.equal(off.statusCode, 401, `真库停用属主必须 401(实际 ${off.statusCode}:${off.body})`) assert.equal(off.json().error, 'instance-owner-unavailable') // ④ 真库 + 无凭据 ⇒ 401 fail-closed const none = await app.inject({ method: 'GET', url: '/api/admin/tenants' }) assert.equal(none.statusCode, 401) assert.equal(none.json().error, 'unauthorized') // ⑤ 真库 + 未签发凭据 ⇒ 401 具名 const bogus = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: tokenHeader('B'.repeat(43)), }) assert.equal(bogus.statusCode, 401) assert.equal(bogus.json().error, 'instance-token-rejected') })