/** * S9 多租户 · **平台侧那一半**的单测。 * 运行:`node --test test/tenants.test.mjs`(⛔ 不连真库、⛔ 不起真进程 —— 本用例测的是端点判据)。 * * ## 覆盖什么(对应本棒的验收点) * ① **非管理员 ⇒ 拒绝** —— 匿名 401、普通用户 403(列表 / 详情 / 确保 / 回收四条全覆盖); * ② **列表形状(含配额字段)** —— 每项必须带「标识 / 显示名 / 实例状态 / 配额读数 / 成员读数 / 创建时间」; * ③ **「确保隔离实例」走的是 supervisor** —— 用注入桩断言 `supervisor.launch` 被调用, * ⛔ 不是路由层自己 spawn(真起进程的用例在别处,本文件一个进程都不起); * ④ **没有数据源 ⇒ 明确回「未接入」** —— 账单 / 用量 / 配额 / 成员均不许编数(⛔ 不许出现 0 冒充)。 * * ## 口径提醒 * - 租户 = 既有账号(`users.id`,网名 `u:`);**数据隔离 = 逻辑隔离**,本文件同样不建表; * - 会话解析走桩(`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' }]) })