221 lines
10 KiB
JavaScript
221 lines
10 KiB
JavaScript
/**
|
||
* 覆盖网络 · **区域名录** `GET /api/overlay/zones` 单测(F6 第一条最小竖切)。
|
||
*
|
||
* 运行:`node --test test/overlay-zones.test.mjs`(测 `lib/` 产物 ⇒ 先 `npm run build`)。
|
||
* ⚠️ 本文件**不连库**:区数据源与凭据判据都是路由的**注入点**(见 `OverlayZonesOptions`),
|
||
* 真验签那条路(T7/T8)用的是**一次性测试签名者**,⛔ 与生产密钥无关。
|
||
*
|
||
* ## 这个文件要回答的四个问题(= 本棒的验收判据)
|
||
* | # | 判据 | 落点 |
|
||
* |---|---|---|
|
||
* | ① | **无凭据 ⇒ 401 fail-closed** | T1 / T2 |
|
||
* | ② | **无有效邀请码 ⇒ 空列表**(⛔ 不报错、⛔ 不回全量) | T3 / T4 |
|
||
* | ③ | **有成员关系 ⇒ 可见;无成员关系 ⇒ 不可见**(逐用户独立) | T5 / T6 |
|
||
* | ④ | **邀请码真的走验签**(受信签名者 / 有效期 / 载荷形状) | T7 / T8 |
|
||
*/
|
||
import assert from 'node:assert/strict'
|
||
import { randomBytes } from 'node:crypto'
|
||
import { test } from 'node:test'
|
||
|
||
import Fastify from 'fastify'
|
||
|
||
import { generateAuthorityKey, signPayloadWith } from '../lib/net/relay/identity.js'
|
||
import { networkInvitePayload, newInviteNonce } from '../lib/net/relay/registry.js'
|
||
import {
|
||
INVITE_SIGNERS_ENV,
|
||
OVERLAY_INVITE_HEADER,
|
||
decodeInviteCode,
|
||
defaultInviteCheck,
|
||
overlayZoneRoutes,
|
||
visibleZones,
|
||
} from '../lib/web/routes/overlay-zones.js'
|
||
|
||
// ── 夹具(⛔ 不连库、⛔ 不起真进程)───────────────────────────────────────────
|
||
|
||
/** 一个可用用户行(`toPublicUser` 只读这五个字段)。 */
|
||
function userRow(id) {
|
||
return { id, username: `u-${id}`, role: 'user', created_at: 1, shared_model_granted: 0 }
|
||
}
|
||
|
||
/**
|
||
* 造一个 Fastify 应用:假 `db`(只够 `requireAuthOrInstanceToken` 解析 cookie)+
|
||
* 注入的区数据源 / 凭据判据。
|
||
*
|
||
* @param regionsFor - 名录里那些区**属于谁**(缺省 = 当前这个用户)。⛔ 与 `userId` 分开设,
|
||
* 才能构造「**别人的**成员关系」那种情形(T6 的判据就靠它)。
|
||
*/
|
||
async function buildApp({ userId = 'u1', regions = [], regionsFor, verifyInvite } = {}) {
|
||
const owner = regionsFor ?? userId
|
||
const app = Fastify()
|
||
app.decorate('db', {
|
||
findSessionWithUser: async () => ({ expiresAt: Date.now() + 60_000, user: userRow(userId) }),
|
||
findUserById: async () => undefined,
|
||
})
|
||
await app.register(overlayZoneRoutes, {
|
||
source: { regionsOfMember: async (memberId) => (memberId === owner ? regions : []) },
|
||
...(verifyInvite === undefined ? {} : { verifyInvite }),
|
||
})
|
||
return app
|
||
}
|
||
|
||
/** 一次带 cookie 的取名录请求。 */
|
||
async function fetchZones(app, headers = {}) {
|
||
const res = await app.inject({
|
||
method: 'GET',
|
||
url: '/api/overlay/zones',
|
||
headers: { cookie: 'sid=test-session', ...headers },
|
||
})
|
||
return { status: res.statusCode, body: res.json() }
|
||
}
|
||
|
||
/** 一枚**真签发**的邀请码(`base64url(JSON.stringify({doc, sig}))`)。 */
|
||
function signedInvite(privPem, { network = 'ops', expiresInMs = 600_000 } = {}) {
|
||
const doc = {
|
||
version: 1,
|
||
network,
|
||
nonce: newInviteNonce(randomBytes),
|
||
issuedAt: new Date().toISOString(),
|
||
expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
|
||
}
|
||
const sig = signPayloadWith(privPem, networkInvitePayload(doc))
|
||
return Buffer.from(JSON.stringify({ doc, sig }), 'utf8').toString('base64url')
|
||
}
|
||
|
||
const OK_INVITE = () => ({ ok: true, network: 'ops' })
|
||
|
||
// ── ① 无凭据 ⇒ 401 ─────────────────────────────────────────────────────────
|
||
|
||
test('T1 无任何凭据 ⇒ 401 fail-closed(⛔ 不回空列表冒充成功)', async () => {
|
||
const app = await buildApp({ regions: ['r1'], verifyInvite: OK_INVITE })
|
||
const res = await app.inject({ method: 'GET', url: '/api/overlay/zones' })
|
||
assert.equal(res.statusCode, 401)
|
||
assert.deepEqual(res.json(), { error: 'unauthorized' })
|
||
await app.close()
|
||
})
|
||
|
||
test('T2 实例码头缺席(只有 region 头别的东西)⇒ 仍 401', async () => {
|
||
const app = await buildApp({ regions: ['r1'], verifyInvite: OK_INVITE })
|
||
const res = await app.inject({
|
||
method: 'GET',
|
||
url: '/api/overlay/zones',
|
||
headers: { 'x-dsh-im-region': 'r1' },
|
||
})
|
||
assert.equal(res.statusCode, 401)
|
||
await app.close()
|
||
})
|
||
|
||
// ── ② 邀请码闸 ─────────────────────────────────────────────────────────────
|
||
|
||
test('T3 有凭据但**没带邀请码** ⇒ 200 且空列表(⛔ 不是 401、⛔ 不是全量)', async () => {
|
||
const app = await buildApp({ regions: ['r1', 'r2'], verifyInvite: undefined })
|
||
// 未注入判据 ⇒ 走缺省真验签;环境变量未配受信签名者 ⇒ 一律不认。
|
||
const before = process.env[INVITE_SIGNERS_ENV]
|
||
delete process.env[INVITE_SIGNERS_ENV]
|
||
try {
|
||
const { status, body } = await fetchZones(app)
|
||
assert.equal(status, 200)
|
||
assert.deepEqual(body, { zones: [], count: 0 })
|
||
} finally {
|
||
if (before !== undefined) process.env[INVITE_SIGNERS_ENV] = before
|
||
await app.close()
|
||
}
|
||
})
|
||
|
||
test('T4 邀请码**验不通** ⇒ 空列表(即使确有成员关系)', async () => {
|
||
for (const reason of ['missing', 'bad-code', 'signature-mismatch', 'expired', 'no-trusted-signer']) {
|
||
const app = await buildApp({ regions: ['r1'], verifyInvite: () => ({ ok: false, reason }) })
|
||
const { status, body } = await fetchZones(app, { [OVERLAY_INVITE_HEADER]: 'whatever' })
|
||
assert.equal(status, 200, reason)
|
||
assert.deepEqual(body, { zones: [], count: 0 }, reason)
|
||
await app.close()
|
||
}
|
||
})
|
||
|
||
// ── ③ 可见性 = 用户 × 区成员关系 ────────────────────────────────────────────
|
||
|
||
test('T5 有成员关系 ⇒ 可见,且去重 / 升序 / 空区不算一个区', async () => {
|
||
const app = await buildApp({ userId: 'u1', regions: ['r2', 'r1', 'r2', ''], verifyInvite: OK_INVITE })
|
||
const { status, body } = await fetchZones(app, { [OVERLAY_INVITE_HEADER]: 'x' })
|
||
assert.equal(status, 200)
|
||
assert.deepEqual(body, { zones: [{ region: 'r1' }, { region: 'r2' }], count: 2 })
|
||
await app.close()
|
||
})
|
||
|
||
test('T6 无成员关系 ⇒ 不返回(逐用户独立,⛔ 不退化成"全体可见")', async () => {
|
||
// mine:r1 是**我**的成员关系 ⇒ 回 r1。
|
||
const mine = await buildApp({ userId: 'u1', regions: ['r1'], verifyInvite: OK_INVITE })
|
||
// other:名录里 r9 是 **u1** 的成员关系,而当前请求者是 **u2** ⇒ 一个都不回
|
||
// (判据 = 路由必须按 `request.user.id` 取,⛔ 不是"谁问都给同一份")。
|
||
const other = await buildApp({ userId: 'u2', regions: ['r9'], regionsFor: 'u1', verifyInvite: OK_INVITE })
|
||
const a = await fetchZones(mine, { [OVERLAY_INVITE_HEADER]: 'x' })
|
||
const b = await fetchZones(other, { [OVERLAY_INVITE_HEADER]: 'x' })
|
||
assert.deepEqual(a.body.zones, [{ region: 'r1' }])
|
||
assert.deepEqual(b.body, { zones: [], count: 0 }, 'u2 不在任何区 ⇒ 一个都不回')
|
||
await mine.close()
|
||
await other.close()
|
||
})
|
||
|
||
// ── ④ 真验签路径(缺省判据)────────────────────────────────────────────────
|
||
|
||
test('T7 真签发的邀请码 ⇒ 名录可出;同一枚码在**换过受信签名者**后 ⇒ 空列表', async () => {
|
||
const signer = generateAuthorityKey()
|
||
const code = signedInvite(signer.privateKeyPem)
|
||
const app = await buildApp({ regions: ['r1', 'r3'] }) // 不注入判据 ⇒ 走缺省真验签
|
||
const before = process.env[INVITE_SIGNERS_ENV]
|
||
try {
|
||
process.env[INVITE_SIGNERS_ENV] = signer.publicKey
|
||
const good = await fetchZones(app, { [OVERLAY_INVITE_HEADER]: code })
|
||
assert.deepEqual(good.body.zones, [{ region: 'r1' }, { region: 'r3' }])
|
||
|
||
// 换一把不相关的签名者 ⇒ 同一枚码**不认** ⇒ 空列表(⛔ 不降级放行)
|
||
process.env[INVITE_SIGNERS_ENV] = generateAuthorityKey().publicKey
|
||
const bad = await fetchZones(app, { [OVERLAY_INVITE_HEADER]: code })
|
||
assert.equal(bad.status, 200)
|
||
assert.deepEqual(bad.body, { zones: [], count: 0 })
|
||
} finally {
|
||
if (before === undefined) delete process.env[INVITE_SIGNERS_ENV]
|
||
else process.env[INVITE_SIGNERS_ENV] = before
|
||
await app.close()
|
||
}
|
||
})
|
||
|
||
test('T8 过期 / 未标定网 / 裸 JSON 三种形态的判据', async () => {
|
||
const signer = generateAuthorityKey()
|
||
// ① 过期:`expired`
|
||
const expired = signedInvite(signer.privateKeyPem, { expiresInMs: -1000 })
|
||
assert.equal(
|
||
defaultInviteCheck(expired, [signer.publicKey]).ok,
|
||
false,
|
||
)
|
||
// ② 网标定非法(`parseNetworkInvite` 严格)⇒ 不认
|
||
const badNet = Buffer.from(
|
||
JSON.stringify({ doc: { version: 1, network: 'not-a-network', nonce: 'f'.repeat(32), issuedAt: new Date().toISOString(), expiresAt: '' }, sig: 'zz' }),
|
||
'utf8',
|
||
).toString('base64url')
|
||
assert.equal(defaultInviteCheck(badNet, [signer.publicKey]).ok, false)
|
||
// ③ 裸 JSON 形态也认(排障用)
|
||
const doc = {
|
||
version: 1,
|
||
network: 'ops',
|
||
nonce: newInviteNonce(randomBytes),
|
||
issuedAt: new Date().toISOString(),
|
||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||
}
|
||
const raw = JSON.stringify({ doc, sig: signPayloadWith(signer.privateKeyPem, networkInvitePayload(doc)) })
|
||
assert.deepEqual(decodeInviteCode(raw)?.doc, doc)
|
||
assert.equal(defaultInviteCheck(raw, [signer.publicKey]).ok, true)
|
||
// ④ 空 / 乱码 ⇒ 不认(⛔ 不抛)
|
||
for (const bad of [undefined, '', ' ', 'not-base64-json', 'e30=' /* {} */]) {
|
||
assert.equal(defaultInviteCheck(bad, [signer.publicKey]).ok, false, String(bad))
|
||
}
|
||
})
|
||
|
||
// ── 纯函数(判据本体)──────────────────────────────────────────────────────
|
||
|
||
test('T9 visibleZones:邀请码没验通 ⇒ 恒空(⛔ 不"降级成只回自己的")', () => {
|
||
assert.deepEqual(visibleZones(['r1', 'r2'], false), [])
|
||
assert.deepEqual(visibleZones([], true), [])
|
||
assert.deepEqual(visibleZones(['', ' ', 'r1'], true), ['r1'])
|
||
assert.deepEqual(visibleZones(['b', 'a', 'b'], true), ['a', 'b'])
|
||
})
|