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

506 lines
23 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.
/**
* 覆盖网络 · **邀请码发放端**(`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` 注入点)⇒ 不写 `<data-root>/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/<nonce>.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
}
})