375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
/**
|
||
* 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' }])
|
||
})
|