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

375 lines
16 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 多租户 · **平台侧那一半**的单测。
* 运行:`node --test test/tenants.test.mjs`(⛔ 不连真库、⛔ 不起真进程 —— 本用例测的是端点判据)。
*
* ## 覆盖什么(对应本棒的验收点)
* ① **非管理员 ⇒ 拒绝** —— 匿名 401、普通用户 403(列表 / 详情 / 确保 / 回收四条全覆盖);
* ② **列表形状(含配额字段)** —— 每项必须带「标识 / 显示名 / 实例状态 / 配额读数 / 成员读数 / 创建时间」;
* ③ **「确保隔离实例」走的是 supervisor** —— 用注入桩断言 `supervisor.launch` 被调用,
* ⛔ 不是路由层自己 spawn(真起进程的用例在别处,本文件一个进程都不起);
* ④ **没有数据源 ⇒ 明确回「未接入」** —— 账单 / 用量 / 配额 / 成员均不许编数(⛔ 不许出现 0 冒充)。
*
* ## 口径提醒
* - 租户 = 既有账号(`users.id`,网名 `u:<id>`);**数据隔离 = 逻辑隔离**,本文件同样不建表;
* - 会话解析走桩(`findSessionWithUser` 直接回一行)—— 会话层不是本文件的被测对象。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import Fastify from 'fastify'
import { tenantRoutes } from '../lib/web/routes/tenants.js'
/* ─────────────────────────── 桩 ─────────────────────────── */
// ⚠️ 两个形状都要给:列表走 `listPublicUsers()`(`PublicUser`:`createdAt`),
// 详情走 `findUserById()`(整行 `User`:`created_at`)—— 端点的两条路径各读各的那一个。
const ADMIN_ROW = { id: 'u-admin', username: 'admin', role: 'admin', createdAt: 1, created_at: 1, 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,
}
/** 会话桩:**不解析 cookie**,直接回当前设定的一行(会话层不在本文件被测范围)。 */
function sessionRow(user) {
return user === null ? undefined : { user, expiresAt: Date.now() + 3_600_000 }
}
/**
* 起一个**只挂 tenants 路由**的 Fastify,全部外部依赖走桩。
*
* `opts`:
* - `user` 当前请求被认成谁(`null` = 匿名)
* - `quotaInfo` 配额观测面桩(`undefined` = 本部署没有该面 ⇒ 必须回 `not_connected`)
* - `launch` `supervisor.launch` 桩;缺省 = 记录调用并回一个假实例
* - `stop` `supervisor.stop` 桩
* - `extras` 注入到 `db` 上的可选扩展面(`listTenantMembers` / `listDshHosts`)
*/
async function buildApp(opts = {}) {
const calls = { launch: [], stop: [], quota: [] }
const instances = opts.instances ?? new Map()
const app = Fastify()
let current = opts.user === undefined ? USER_ROW : opts.user
app.decorate('db', {
findSessionWithUser: async () => sessionRow(current),
listPublicUsers: async () => opts.users ?? [USER_ROW],
findUserById: async (id) => (opts.users ?? [USER_ROW]).find((u) => u.id === id),
...(opts.extras ?? {}),
})
app.decorate('config', { baseDomain: 'example.test', enablePatch: false })
// `launchForUser` 只用到 `UserFs` 的这三件事;⛔ 不碰真盘。
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: (userId) => {
calls.quota.push(userId)
return 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)
},
})
await app.register(tenantRoutes)
await app.ready()
return {
app,
calls,
cookies: {
admin: { cookie: 'sid=admin-token' },
user: { cookie: 'sid=user-token' },
},
as: (u) => {
current = u
},
}
}
/* ─────────────────── ① 非管理员 ⇒ 拒绝 ─────────────────── */
test('S9-① 四条端点:匿名 401、普通用户 403(管理面不得放宽)', async (t) => {
const { app, cookies, as } = await buildApp()
t.after(() => app.close())
const cases = [
['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'],
]
as(null)
for (const [method, url] of cases) {
const res = await app.inject({ method, url, payload: {} })
assert.equal(res.statusCode, 401, `${method} ${url} 匿名应当 401(实际 ${res.statusCode})`)
}
as(USER_ROW)
for (const [method, url] of cases) {
const res = await app.inject({ method, url, headers: cookies.user, payload: {} })
assert.equal(res.statusCode, 403, `${method} ${url} 普通用户应当 403(实际 ${res.statusCode})`)
}
})
test('S9-① 管理员可达;租户不存在 ⇒ 具名 404(⛔ 不静默成功)', async (t) => {
const { app, cookies, as } = await buildApp()
t.after(() => app.close())
as(ADMIN_ROW)
const ok = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: cookies.admin })
assert.equal(ok.statusCode, 200, `管理员应当 200(实际 ${ok.statusCode}:${ok.body})`)
const missing = await app.inject({ method: 'GET', url: '/api/admin/tenants/nope', headers: cookies.admin })
assert.equal(missing.statusCode, 404)
assert.equal(missing.json().error, 'tenant_not_found')
const missingEnsure = await app.inject({
method: 'POST',
url: '/api/admin/tenants/nope/instance/ensure',
headers: cookies.admin,
payload: {},
})
assert.equal(missingEnsure.statusCode, 404)
})
/* ─────────────────── ② 列表形状(含配额字段) ─────────────────── */
test('S9-② 列表形状:标识 / 显示名 / 实例状态 / 配额 / 成员数 / 创建时间', async (t) => {
const instances = new Map([
['u-1', { main: { id: 'inst-7', port: 20123, status: 'running', restarts: 2, lastCrashedAt: null }, watchdog: { status: 'running' } }],
])
const { app, cookies, as } = await buildApp({
quotaInfo: { baseMb: 640, memMb: 2048, heapMb: 300 },
instances,
})
t.after(() => app.close())
as(ADMIN_ROW)
const res = await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: cookies.admin })
assert.equal(res.statusCode, 200, res.body)
const body = res.json()
assert.equal(body.total, 1)
assert.deepEqual(body.isolation, {
data: 'logical(同库 · 按既有归属)',
instance: 'per-tenant(既有按需实例编排)',
})
const one = body.tenants[0]
for (const key of ['id', 'username', 'displayName', 'role', 'createdAt', 'instance', 'quota', 'members', 'billing']) {
assert.ok(key in one, `列表项缺少字段 ${key}(形状:${Object.keys(one).join(',')})`)
}
assert.equal(one.id, 'u-1')
assert.equal(one.network, 'u:u-1', '租户网名必须与 relay 侧 tenantNetworkOf 同口径')
assert.equal(one.createdAt, new Date(1_700_000_000_000).toISOString())
// 实例状态必须来自 supervisor.status(既有唯一实现)
assert.equal(one.instance.running, true)
assert.equal(one.instance.status, 'running')
assert.equal(one.instance.instanceId, 'inst-7')
assert.equal(one.instance.port, 20123)
assert.equal(one.instance.restarts, 2)
// 🔴 配额:字段齐 + **值必须跟着注入源变**(证伪"路由里写死常量")
assert.equal(one.quota.state, 'ok')
assert.deepEqual(
{ baseMb: one.quota.baseMb, memMb: one.quota.memMb, heapMb: one.quota.heapMb },
{ baseMb: 640, memMb: 2048, heapMb: 300 },
)
assert.match(one.quota.source, /quotaInfo/)
})
test('S9-② 配额随宿主规格变:同一份代码、换一组宿主读数 ⇒ 端点读数跟着变(⛔ 非写死)', async (t) => {
const small = await buildApp({ quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 } })
const big = await buildApp({ quotaInfo: { baseMb: 896, memMb: 4096, heapMb: 512 } })
t.after(() => small.app.close())
t.after(() => big.app.close())
small.as(ADMIN_ROW)
big.as(ADMIN_ROW)
const a = (await small.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: small.cookies.admin })).json()
const b = (await big.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: big.cookies.admin })).json()
assert.equal(a.tenants[0].quota.memMb, 1024)
assert.equal(b.tenants[0].quota.memMb, 4096)
assert.notEqual(a.tenants[0].quota.memMb, b.tenants[0].quota.memMb)
// 读数取数必须发生在**该租户**身上(不是全局常量)
assert.deepEqual(small.calls.quota, ['u-1'])
})
test('S9-② 成员:接了既有成员模型的读数 ⇒ 计数与来源正确', async (t) => {
const { app, cookies, as } = await buildApp({
quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 },
extras: {
listTenantMembers: async (tenantId) => [
{ memberId: `${tenantId}-m1`, kind: 'human', role: 'owner', joinedAt: 1_700_000_000_000 },
{ memberId: `${tenantId}-m2`, kind: 'bot', role: 'member', agentRef: 'a1', autoReply: 1, joinedAt: 1_700_000_001_000 },
],
},
})
t.after(() => app.close())
as(ADMIN_ROW)
const body = (await app.inject({ method: 'GET', url: '/api/admin/tenants/u-1', headers: cookies.admin })).json()
assert.equal(body.members.state, 'ok')
assert.equal(body.members.count, 2, '成员数 = 既有成员模型的读数')
assert.deepEqual(body.members.members.map((m) => m.memberId), ['u-1-m1', 'u-1-m2'])
assert.equal(body.members.members[1].kind, 'bot')
assert.equal(body.members.members[1].autoReply, 1)
})
/* ─────────────────── ③ 确保/回收隔离实例走 supervisor ─────────────────── */
test('S9-③「确保隔离实例」:走 supervisor.launch(路由层⛔不 spawn 进程)', async (t) => {
const { app, calls, cookies, as } = await buildApp({ quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 } })
t.after(() => app.close())
as(ADMIN_ROW)
const res = await app.inject({
method: 'POST',
url: '/api/admin/tenants/u-1/instance/ensure',
headers: cookies.admin,
payload: {},
})
assert.equal(res.statusCode, 200, res.body)
const body = res.json()
assert.equal(body.ok, true)
assert.equal(body.instance.id, 'inst-1')
// 🔴 关键断言:动作**真的**落到了 supervisor 上(而不是路由自己起进程 / 假装成功)
assert.equal(calls.launch.length, 1, 'supervisor.launch 必须被调用恰好一次')
assert.equal(calls.launch[0].userId, 'u-1')
assert.equal(calls.launch[0].folder, '/data/users/u-1/ws/', 'folder 必须经 UserFs 解析后交给 supervisor')
assert.match(body.via, /launchForUser/)
})
test('S9-③ 已在跑 ⇒ 具名 409;不是文件夹 ⇒ 400(都不假装成功)', async (t) => {
const { AlreadyRunningError } = await import('../lib/supervisor/orchestrator.js')
const running = await buildApp({
quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 },
launchThrows: new AlreadyRunningError('u-1'),
})
t.after(() => running.app.close())
running.as(ADMIN_ROW)
const r409 = await running.app.inject({
method: 'POST',
url: '/api/admin/tenants/u-1/instance/ensure',
headers: running.cookies.admin,
payload: {},
})
assert.equal(r409.statusCode, 409)
assert.equal(r409.json().error, 'already_running')
const bad = await buildApp({ quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 }, isDirectory: false })
t.after(() => bad.app.close())
bad.as(ADMIN_ROW)
const r400 = await bad.app.inject({
method: 'POST',
url: '/api/admin/tenants/u-1/instance/ensure',
headers: bad.cookies.admin,
payload: { folder: 'nope' },
})
assert.equal(r400.statusCode, 400)
assert.equal(r400.json().error, 'not_a_folder')
assert.equal(bad.calls.launch.length, 0, '不是文件夹 ⇒ ⛔ 不许走到 supervisor.launch')
})
test('S9-③「回收隔离实例」:走 supervisor.stop + 如实回答回收前是否在跑', async (t) => {
const instances = new Map([['u-1', { main: { id: 'inst-7', port: 20123, status: 'running' }, watchdog: undefined }]])
const { app, calls, cookies, as } = await buildApp({
quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 },
instances,
})
t.after(() => app.close())
as(ADMIN_ROW)
const res = await app.inject({
method: 'POST',
url: '/api/admin/tenants/u-1/instance/reclaim',
headers: cookies.admin,
})
assert.equal(res.statusCode, 200, res.body)
assert.equal(res.json().ok, true)
assert.equal(res.json().reclaimed, true, '回收前确实在跑 ⇒ reclaimed 必须为 true')
assert.deepEqual(calls.stop, ['u-1'], '动作必须落到 supervisor.stop')
assert.match(res.json().via, /supervisor\.stop/)
// 已经不在跑的租户:reclaimed 必须如实为 false(⛔ 不许恒 true 假装成功)
const again = await app.inject({
method: 'POST',
url: '/api/admin/tenants/u-1/instance/reclaim',
headers: cookies.admin,
})
assert.equal(again.json().reclaimed, false)
})
/* ─────────────────── ④ 没有数据源 ⇒ 明确「未接入」 ─────────────────── */
test('S9-④ 没有数据源:账单 / 用量 / 配额 / 成员一律回 not_connected(⛔ 不编数)', async (t) => {
// 本部署:spawner 没有 quotaInfo、db 没有成员读数 ⇒ 三处都必须明确说"未接入"
const { app, cookies, as } = await buildApp({ quotaInfo: undefined, extras: {} })
t.after(() => app.close())
as(ADMIN_ROW)
const list = (await app.inject({ method: 'GET', url: '/api/admin/tenants', headers: cookies.admin })).json()
const item = list.tenants[0]
assert.equal(item.billing.state, 'not_connected')
assert.match(item.billing.reason, /未接入/)
assert.equal(item.quota.state, 'not_connected')
assert.equal(item.members.state, 'not_connected')
assert.equal(item.members.count, null, '⛔ 读不到成员数就必须是 null,不许用 0 冒充')
assert.deepEqual(item.members.members, [])
const detail = (await app.inject({ method: 'GET', url: '/api/admin/tenants/u-1', headers: cookies.admin })).json()
assert.equal(detail.billing.state, 'not_connected')
assert.equal(detail.usage.state, 'not_connected')
assert.match(detail.usage.reason, /没有|不存在|未接入/)
assert.equal(detail.quota.state, 'not_connected')
assert.equal(detail.members.count, null)
// ⛔ 不许凭空造出金额 / 用量字段
for (const key of ['amount', 'cost', 'price', 'tokenUsed', 'usedMb', 'totalMb']) {
assert.equal(key in detail.billing, false, `账单里⛔不许出现编造的字段 ${key}`)
assert.equal(key in detail.usage, false, `用量里⛔不许出现编造的字段 ${key}`)
}
})
test('S9-④ 宿主台账未接线 ⇒ 列表不带 hosts(⛔ 不编宿主规格)', async (t) => {
const noHosts = await buildApp({ quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 } })
t.after(() => noHosts.app.close())
noHosts.as(ADMIN_ROW)
const a = (await noHosts.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: noHosts.cookies.admin })).json()
assert.equal('hosts' in a, false)
const withHosts = await buildApp({
quotaInfo: { baseMb: 448, memMb: 1024, heapMb: 256 },
extras: { listDshHosts: async () => [{ id: 'h1', capacityMb: 1870, usedMb: 1024, status: 'up' }] },
})
t.after(() => withHosts.app.close())
withHosts.as(ADMIN_ROW)
const b = (await withHosts.app.inject({ method: 'GET', url: '/api/admin/tenants', headers: withHosts.cookies.admin })).json()
assert.deepEqual(b.hosts, [{ id: 'h1', endpoint: null, capacityMb: 1870, usedMb: 1024, status: 'up' }])
})