506 lines
23 KiB
JavaScript
506 lines
23 KiB
JavaScript
/**
|
||
* 覆盖网络 · **邀请码发放端**(`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
|
||
}
|
||
})
|