Files
dsh_shenxian/test/tenants-auth.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

488 lines
22 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.
/**
* 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')
})