/** * 覆盖网络 · **邀请码发放端**(`POST/GET /api/admin/overlay-invites` + `…/revoke`)单测。 * * 运行:`node --test test/overlay-invites.test.mjs`(测 `lib/` 产物 ⇒ 先 `npm run build`)。 * ⚠️ 本文件**不连库、不碰真文件系统、不写任何密钥文件**: * · 会话解析 = 假 `db`(只够 `requireAdmin`); * · 签发私钥 = **运行时生成的一次性测试密钥**(`generateAuthorityKey()`,⛔ 与生产密钥无关), * 以**注入的 signer** 交给路由 ⇒ 不需要任何 env、更不需要落一个 PEM 文件; * · 台账 = 内存实现(`ledger` 注入点)⇒ 不写 `/overlay/invites`。 * * ## 这个文件要回答的问题(= 本棒验收判据) * | # | 判据 | 落点 | * |---|---|---| * | ① | **闭环**:管理员签发 ⇒ 消费端(`GET /api/overlay/zones`)能验通 ⇒ 名录真出区 | T1 | * | ② | **非管理员 / 无凭据 ⇒ 拒**(403 / 401,三条端点全覆盖) | T2 | * | ③ | **过期码 ⇒ 消费端不认**(具名 `expired`) | T3 | * | ④ | **篡改载荷(网名 / 有效期 / nonce)⇒ 不认** | T4 | * | ⑤ | **私钥没配 ⇒ 503 具名"未配置"**(⛔ 不瞎签、⛔ 无落账) | T5 | * | ⑥ | **非法区名 / 非法有效期 ⇒ 400**(⛔ 不签废码、⛔ 无落账) | T6 | * | ⑦ | **撤销**:占位 nonce + 列出台账状态 + 重复撤 ⇒ 409 | T7 | * | ⑧ | **台账不含签名**(元数据不是一枚可用邀请码) | T8 | * | ⑨ | **撤销不改变名录**(把已知边界钉住,⛔ 防止被误读成"彻底失效") | T7 | */ import assert from 'node:assert/strict' import { createRequire } from 'node:module' import { join } from 'node:path' import { test } from 'node:test' import Fastify from 'fastify' import { generateAuthorityKey } from '../lib/net/relay/identity.js' import { INVITE_SIGNERS_ENV, OVERLAY_INVITE_HEADER, decodeInviteCode, defaultInviteCheck, overlayZoneRoutes, } from '../lib/web/routes/overlay-zones.js' import { INVITE_SIGNER_KEY_ENV, INVITE_SIGNER_KEY_FILE_ENV, INVITE_TTL_MINUTES_DEFAULT, INVITE_TTL_MINUTES_MAX, buildInvite, encodeInviteCode, inviteLedgerDirs, overlayInviteRoutes, resolveInviteSigner, } from '../lib/web/routes/overlay-invites.js' // ── 夹具(⛔ 不连库、⛔ 不落盘、⛔ 不写密钥文件)────────────────────────────── /** 可用用户行(`toPublicUser` 只读这五个字段)。 */ function userRow(id, role) { return { id, username: `u-${id}`, role, created_at: 1, shared_model_granted: 0 } } /** 内存台账(与 `fileLedger` 同接口;`revoked` 集合就是 `consumeNonce` 在真实现里的占位目录)。 */ function memLedger() { const records = new Map() const consumed = new Set() return { dirs: { consumed: '(mem)/consumed', issued: '(mem)/invites' }, record(rec) { records.set(rec.nonce, rec) return { ok: true } }, list() { return [...records.values()].sort((a, b) => b.issuedAt.localeCompare(a.issuedAt)) }, isRevoked(nonce) { return consumed.has(nonce) }, revoke(nonce) { if (consumed.has(nonce)) return { ok: false, reason: 'invite-already-used' } consumed.add(nonce) return { ok: true } }, _size: () => records.size, _consumed: consumed, } } /** 一次性测试签名者(注入给路由 ⇒ 路由不需要任何 env 即可签发)。 */ function signerOf(key) { return () => ({ ok: true, pem: key.privateKeyPem, publicKey: key.publicKey }) } /** * 造一个 Fastify 应用:**发放端 + 消费端同挂一个 app**(闭环用例要的正是这个)。 * * @param opts.signer - 不传 ⇒ 走缺省 `resolveInviteSigner()`(读 env;T5 用它)。 * @param opts.now - 注入时钟(T3 的"过期"就靠"签发时刻在过去"构造,⛔ 不真等)。 */ async function buildApp(opts = {}) { const { role = 'admin', userId = 'admin1', signer, ledger, now, regions = ['r1', 'r2'], zonesFor } = opts const app = Fastify() app.decorate('db', { findSessionWithUser: async () => ({ expiresAt: Date.now() + 60_000, user: userRow(userId, role) }), findUserById: async () => undefined, }) await app.register(overlayInviteRoutes, { ...(signer === undefined ? {} : { signer }), ledger: ledger ?? memLedger(), ...(now === undefined ? {} : { now }), }) await app.register(overlayZoneRoutes, { source: { regionsOfMember: async (memberId) => (memberId === (zonesFor ?? userId) ? regions : []) }, }) return app } const COOKIE = { cookie: 'sid=test-session' } async function issue(app, payload, headers = COOKIE) { const res = await app.inject({ method: 'POST', url: '/api/admin/overlay-invites', headers, payload }) return { status: res.statusCode, body: res.json() } } async function listInvites(app, headers = COOKIE) { const res = await app.inject({ method: 'GET', url: '/api/admin/overlay-invites', headers }) return { status: res.statusCode, body: res.json() } } async function revoke(app, nonce, headers = COOKIE) { const res = await app.inject({ method: 'POST', url: '/api/admin/overlay-invites/revoke', headers, payload: { nonce }, }) return { status: res.statusCode, body: res.json() } } /** 消费端取名录(邀请码走请求头)。 */ async function fetchZones(app, code, headers = COOKIE) { const res = await app.inject({ method: 'GET', url: '/api/overlay/zones', headers: code === undefined ? headers : { ...headers, [OVERLAY_INVITE_HEADER]: code }, }) return { status: res.statusCode, body: res.json() } } /** 把一枚码解回 `{doc,sig}`(篡改用)。 */ function rawOf(code) { return JSON.parse(Buffer.from(code, 'base64url').toString('utf8')) } function codeOf(raw) { return Buffer.from(JSON.stringify(raw), 'utf8').toString('base64url') } /** 临时改 env 并在 finally 里原样还原(⛔ 不留污染)。 */ function withEnv(pairs, fn) { const saved = new Map() for (const [k] of pairs) saved.set(k, process.env[k]) for (const [k, v] of pairs) { if (v === undefined) delete process.env[k] else process.env[k] = v } return Promise.resolve() .then(fn) .finally(() => { for (const [k, v] of saved) { if (v === undefined) delete process.env[k] else process.env[k] = v } }) } const EMPTY = { zones: [], count: 0 } // ── ① 闭环:签发 ⇒ 消费端验通 ──────────────────────────────────────────────── test('T1 闭环:管理员签发 ⇒ 消费端用同一对密钥验通 ⇒ 名录出区(⛔ 不是"签得出来")', async () => { const key = generateAuthorityKey() const app = await buildApp({ signer: signerOf(key) }) try { const issued = await issue(app, { region: 'ops', ttlMinutes: 10 }) assert.equal(issued.status, 200, JSON.stringify(issued.body)) assert.equal(issued.body.ok, true) assert.equal(issued.body.region, 'ops') assert.equal(issued.body.header, OVERLAY_INVITE_HEADER) assert.equal(issued.body.signer.publicKey, key.publicKey, '回执必须给出"要填进消费端的那把公钥"') assert.equal('privateKeyPem' in issued.body.signer, false, '⛔ 回执里不许出现私钥') assert.equal(JSON.stringify(issued.body).includes('PRIVATE KEY'), false, '⛔ 整份回执里不许出现私钥 PEM') // 消费端受信集 = 签发公钥 ⇒ 名录真的出区 await withEnv([[INVITE_SIGNERS_ENV, key.publicKey]], async () => { const got = await fetchZones(app, issued.body.code) assert.equal(got.status, 200) assert.deepEqual(got.body.zones, [{ region: 'r1' }, { region: 'r2' }]) // 反证:换一把不相关的受信签名者 ⇒ 同一枚码不认(证明上面的成功来自**真验签**) await withEnv([[INVITE_SIGNERS_ENV, generateAuthorityKey().publicKey]], async () => { const bad = await fetchZones(app, issued.body.code) assert.equal(bad.status, 200) assert.deepEqual(bad.body, EMPTY) }) // 反证二:受信集**空**(未配)⇒ 一样不认(fail-closed) await withEnv([[INVITE_SIGNERS_ENV, undefined]], async () => { assert.deepEqual((await fetchZones(app, issued.body.code)).body, EMPTY) }) }) } finally { await app.close() } }) // ── ② 鉴权 ──────────────────────────────────────────────────────────────────── test('T2 非管理员 ⇒ 403;无凭据 ⇒ 401(三条端点全覆盖)', async () => { const key = generateAuthorityKey() const nonce = 'a'.repeat(32) for (const role of ['user', 'admin']) { const app = await buildApp({ signer: signerOf(key), role }) try { if (role === 'user') { assert.equal((await issue(app, { region: 'ops' })).status, 403) assert.equal((await listInvites(app)).status, 403) assert.equal((await revoke(app, nonce)).status, 403) assert.deepEqual((await issue(app, { region: 'ops' })).body, { error: 'forbidden' }) } else { assert.equal((await issue(app, { region: 'ops' })).status, 200, '同一条路对 admin 必须通') } // 无 cookie ⇒ 401(fail-closed,⛔ 不是"跳过校验放行") const noAuth = {} assert.equal((await issue(app, { region: 'ops' }, noAuth)).status, 401) assert.equal((await listInvites(app, noAuth)).status, 401) assert.equal((await revoke(app, nonce, noAuth)).status, 401) } finally { await app.close() } } }) // ── ③ 过期 ──────────────────────────────────────────────────────────────────── test('T3 过期码 ⇒ 消费端不认(具名 expired;签发那一刻仍算有效,故签得出来)', async () => { const key = generateAuthorityKey() // 签发时钟拨到 1 小时前 + TTL 1 分钟 ⇒ 相对"现在"早已过期 const signAt = Date.now() - 3_600_000 const app = await buildApp({ signer: signerOf(key), now: () => signAt }) try { const issued = await issue(app, { region: 'ops', ttlMinutes: 1 }) assert.equal(issued.status, 200, '签发时钟下它是有效的 ⇒ 应当签得出来') assert.ok(Date.parse(issued.body.expiresAt) < Date.now(), '这枚码的有效期确实已过') await withEnv([[INVITE_SIGNERS_ENV, key.publicKey]], async () => { const got = await fetchZones(app, issued.body.code) assert.equal(got.status, 200) assert.deepEqual(got.body, EMPTY, '过期 ⇒ 空列表(⛔ 不降级放行)') // 具名原因(排障面):时间到点 ⇒ expired const verdict = defaultInviteCheck(issued.body.code, [key.publicKey]) assert.equal(verdict.ok, false) assert.equal(verdict.reason, 'expired') }) } finally { await app.close() } }) // ── ④ 篡改 ──────────────────────────────────────────────────────────────────── test('T4 篡改载荷(网名 / 有效期 / nonce)⇒ 消费端不认', async () => { const key = generateAuthorityKey() const app = await buildApp({ signer: signerOf(key) }) try { const issued = await issue(app, { region: 'ops', ttlMinutes: 10 }) assert.equal(issued.status, 200) const good = rawOf(issued.body.code) await withEnv([[INVITE_SIGNERS_ENV, key.publicKey]], async () => { // ⓪ 未篡改 ⇒ 认(对照) assert.deepEqual((await fetchZones(app, codeOf(good))).body.zones, [{ region: 'r1' }, { region: 'r2' }]) // ① 换网名(**合法**网名,仍会被签名挡下 ⇒ 证明判据覆盖载荷内容,不是"只看形状") assert.deepEqual( (await fetchZones(app, codeOf({ ...good, doc: { ...good.doc, network: 'anothernet' } }))).body, EMPTY, '改网名', ) // ② 拉长有效期(10 年) assert.deepEqual( ( await fetchZones( app, codeOf({ ...good, doc: { ...good.doc, expiresAt: new Date(Date.now() + 315_360_000_000).toISOString() } }), ) ).body, EMPTY, '改有效期', ) // ③ 换 nonce assert.deepEqual( (await fetchZones(app, codeOf({ ...good, doc: { ...good.doc, nonce: 'b'.repeat(32) } }))).body, EMPTY, '改 nonce', ) // ④ 换签名(另一把钥匙对**同一份** doc 的签名 —— 载荷一字未改,只有签名换了) const other = generateAuthorityKey() const foreign = buildInvite({ network: 'ops', ttlMinutes: 10, pem: other.privateKeyPem, nonce: good.doc.nonce, now: Date.parse(good.doc.issuedAt), }) assert.equal(foreign.ok, true) assert.deepEqual( (await fetchZones(app, codeOf({ doc: good.doc, sig: foreign.invite.sig }))).body, EMPTY, '换签名 ⇒ 不认', ) // ⑤ 非签名者自己的钥匙签的**整枚**码 ⇒ 也不认(受信集里没有它) assert.deepEqual((await fetchZones(app, foreign.invite.code)).body, EMPTY) }) } finally { await app.close() } }) // ── ⑤ 私钥没配 ⇒ 不瞎签 ────────────────────────────────────────────────────── test('T5 未配私钥 ⇒ 503 invite-signer-unconfigured;同配两处 ⇒ ambiguous;路径读不到 ⇒ unreadable', async () => { // 纯函数面(三种具名失败;⛔ 都不需要真密钥文件) assert.deepEqual(resolveInviteSigner({}).reason, 'unconfigured') assert.equal( resolveInviteSigner({ [INVITE_SIGNER_KEY_ENV]: 'x', [INVITE_SIGNER_KEY_FILE_ENV]: '/no/such.pem' }).reason, 'ambiguous', ) assert.equal(resolveInviteSigner({ [INVITE_SIGNER_KEY_FILE_ENV]: '/no/such/key.pem' }).reason, 'unreadable') assert.equal(resolveInviteSigner({ [INVITE_SIGNER_KEY_ENV]: 'not-a-pem' }).reason, 'not-ed25519') // 端点面:env 里什么都没配 ⇒ 503,且**台账一条都不落**(⛔ 没有"先记一笔再报错") const ledger = memLedger() const app = await buildApp({ ledger }) try { await withEnv( [ [INVITE_SIGNER_KEY_ENV, undefined], [INVITE_SIGNER_KEY_FILE_ENV, undefined], ], async () => { const res = await issue(app, { region: 'ops' }) assert.equal(res.status, 503) assert.equal(res.body.error, 'invite-signer-unconfigured') assert.match(res.body.detail, /不瞎签|未配置/) assert.equal('code' in res.body, false, '⛔ 失败回执里不许有一枚"签出来的码"') assert.equal(ledger._size(), 0, '⛔ 未签成 ⇒ 不许落账') }, ) } finally { await app.close() } }) // ── ⑥ 非法入参 ⇒ 400(⛔ 不签废码)──────────────────────────────────────────── test('T6 非法区名 ⇒ 400 bad-region(⛔ 不签"永远验不通"的废码);非法 TTL ⇒ 400 bad-ttl', async () => { const key = generateAuthorityKey() const ledger = memLedger() const app = await buildApp({ signer: signerOf(key), ledger }) try { for (const region of ['华东', 'A B', 'u:', '-bad', '']) { const res = await issue(app, { region }) assert.equal(res.status, 400, region) assert.ok(['bad-region', 'bad-body'].includes(res.body.error), `${region} ⇒ ${res.body.error}`) } // region / network 同时给了不同的值 ⇒ 400(⛔ 不猜用哪个) assert.equal((await issue(app, { region: 'ops', network: 'othernet' })).body.error, 'bad-body') // 合法但非法 TTL for (const ttlMinutes of [0, -1, 1.5, INVITE_TTL_MINUTES_MAX + 1, '10']) { const res = await issue(app, { region: 'ops', ttlMinutes }) assert.equal(res.status, 400, String(ttlMinutes)) assert.equal(res.body.error, 'bad-ttl', String(ttlMinutes)) } assert.equal(ledger._size(), 0, '⛔ 400 的请求一律不许落账') // 合法边界:1 与上界都通;缺省 = INVITE_TTL_MINUTES_DEFAULT for (const ttlMinutes of [1, INVITE_TTL_MINUTES_MAX]) { const res = await issue(app, { region: 'ops', ttlMinutes }) assert.equal(res.status, 200, String(ttlMinutes)) assert.equal(res.body.ttlMinutes, ttlMinutes) } const dflt = await issue(app, { region: 'u:alice' }) assert.equal(dflt.body.ttlMinutes, INVITE_TTL_MINUTES_DEFAULT) assert.equal(dflt.body.networkKind, 'tenant') } finally { await app.close() } }) // ── ⑦⑧⑨ 台账 / 撤销 ───────────────────────────────────────────────────────── test('T7 撤销:列出台账状态 ⇒ 占位 nonce ⇒ 重复撤 409;且**不改变名录**(已知边界,⛔ 不夸大)', async () => { const key = generateAuthorityKey() const ledger = memLedger() const app = await buildApp({ signer: signerOf(key), ledger, userId: 'admin9' }) try { const issued = await issue(app, { region: 'ops', ttlMinutes: 10 }) const nonce = issued.body.invite.doc.nonce assert.match(nonce, /^[0-9a-f]{32}$/) assert.equal(ledger._size(), 1, '成功签发 ⇒ 落一条台账') let l = await listInvites(app) assert.equal(l.status, 200) assert.equal(l.body.total, 1) assert.equal(l.body.invites[0].nonce, nonce) assert.equal(l.body.invites[0].network, 'ops') assert.equal(l.body.invites[0].issuedBy, 'admin9', '台账要能回答"这枚码是谁发的"') assert.equal(l.body.invites[0].revoked, false) assert.equal(l.body.invites[0].expired, false) assert.equal((await revoke(app, nonce)).status, 200) l = await listInvites(app) assert.equal(l.body.invites[0].revoked, true) assert.equal(ledger._consumed.has(nonce), true, '撤销 = 占位 nonce(与 consumeNonce 同一判据)') const again = await revoke(app, nonce) assert.equal(again.status, 409) assert.equal(again.body.error, 'invite-already-used-or-revoked') // 形状不对的 nonce ⇒ 400(⛔ 不把"打错字"报成"已撤销") assert.equal((await revoke(app, 'zz')).body.error, 'bad-nonce') // ⑨ 已知边界:撤销**不改变**名录(名录端点只看签名与有效期)—— 钉住它,防止被误读成"彻底失效" await withEnv([[INVITE_SIGNERS_ENV, key.publicKey]], async () => { assert.deepEqual( (await fetchZones(app, issued.body.code)).body.zones, [{ region: 'r1' }, { region: 'r2' }], '撤销只管收单/准入那条路;名录侧仍可见(说明书里必须这么写)', ) }) } finally { await app.close() } }) test('T8 台账里**没有签名**(元数据不是一枚可用邀请码)', async () => { const key = generateAuthorityKey() const ledger = memLedger() const app = await buildApp({ signer: signerOf(key), ledger }) try { assert.equal((await issue(app, { region: 'ops' })).status, 200) const l = await listInvites(app) const rec = l.body.invites[0] assert.deepEqual( Object.keys(rec).sort(), ['expired', 'expiresAt', 'issuedAt', 'issuedBy', 'network', 'nonce', 'revoked', 'version'].sort(), ) assert.equal('sig' in rec, false) assert.equal('code' in rec, false) assert.equal(JSON.stringify(l.body).includes('sig'), false, '⛔ 列表里任何字段都不该携带签名') } finally { await app.close() } }) // ── 纯函数(签发本体)───────────────────────────────────────────────────────── test('T9 buildInvite:合法区名 ⇒ 码能被消费端解码器读回;非法区名 ⇒ 拒签(bad-network)', () => { const key = generateAuthorityKey() const ok = buildInvite({ network: 'ops', ttlMinutes: 5, pem: key.privateKeyPem, now: Date.now(), nonce: 'c'.repeat(32) }) assert.equal(ok.ok, true) assert.equal(ok.publicKey, key.publicKey, '签发自检要能推出与私钥成对的公钥') assert.deepEqual(decodeInviteCode(ok.invite.code)?.doc, ok.invite.doc, '编码 ⇄ 消费端解码器必须闭环') assert.equal(encodeInviteCode({ doc: ok.invite.doc, sig: ok.invite.sig }), ok.invite.code, '编码只有一处口径') assert.equal(defaultInviteCheck(ok.invite.code, [key.publicKey]).ok, true) for (const bad of ['not a net', 'A B', 'u:', '', '-x']) { const res = buildInvite({ network: bad, ttlMinutes: 5, pem: key.privateKeyPem }) assert.equal(res.ok, false, bad) assert.equal(res.reason, 'bad-network', bad) } // 签发私钥不可用(PEM 坏 / 非 ed25519)⇒ 走 `self-check-failed`,⛔ 不返回任何码 const broken = buildInvite({ network: 'ops', ttlMinutes: 5, pem: 'not-a-pem' }) assert.equal(broken.ok, false) assert.equal(broken.reason, 'self-check-failed') }) test('T10 台账落点与运维 CLI 同源(同一套生效配置下);⛔ 不另立第二本账', () => { const cfg = createRequire(import.meta.url)('../config/index.cjs') // ① 覆盖项优先(与 CLI 的 `--dir` / `DSHS_OVERLAY_REGISTRY_DIR` 同键) assert.equal(inviteLedgerDirs({ DSHS_OVERLAY_REGISTRY_DIR: join('/tmp', 'x') }).consumed, join('/tmp', 'x', 'consumed')) // ② 生效配置 = 系统 env(脚本侧 `get()` 的**最高优先级**,⛔ 不是"我重算一遍同样的式子"): // 此时产品侧算出的目录与 CLI `cfg.overlayDir()` **逐字相同** // ⇒ 本文件写下的 `consumed/.used` 就是 CLI `apply` 读的那一个(撤销真的挡得住收单)。 const savedDir = process.env.DSHS_OVERLAY_REGISTRY_DIR const savedRoot = process.env.DSHS_DATA_ROOT try { delete process.env.DSHS_DATA_ROOT process.env.DSHS_OVERLAY_REGISTRY_DIR = join('/prod', 'ov') assert.equal(inviteLedgerDirs().consumed, join(cfg.overlayDir(), 'consumed')) // ③ 只有数据根(无覆盖项)时同样同源 delete process.env.DSHS_OVERLAY_REGISTRY_DIR process.env.DSHS_DATA_ROOT = join('/prod', 'root') assert.equal(inviteLedgerDirs().consumed, join(cfg.overlayDir(), 'consumed')) assert.equal(inviteLedgerDirs().consumed, join('/prod', 'root', 'overlay', 'consumed')) } finally { if (savedDir === undefined) delete process.env.DSHS_OVERLAY_REGISTRY_DIR else process.env.DSHS_OVERLAY_REGISTRY_DIR = savedDir if (savedRoot === undefined) delete process.env.DSHS_DATA_ROOT else process.env.DSHS_DATA_ROOT = savedRoot } })