chore: 文档库全量重新入库(2026-10-20)
build / build-and-scan (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-20 15:05:55 +08:00
commit 5ec99ce227
668 files changed
+164448

No files matched your search

+374
View File
@@ -0,0 +1,374 @@
/**
* 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' }])
})