487 lines
22 KiB
JavaScript
487 lines
22 KiB
JavaScript
/**
|
||||
|
|
* 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')
|
|||
|
|
})
|