Files
dsh_shenxian/test/device-web-switch.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

419 lines
19 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.
/**
* 手机接入线 · **用户级「手机访问」开关**(S5,2026-09-27)—— 判据三层,全部机器可判。
*
* ## 为什么要有这个文件(三条验收面,缺一条都等于没做)
* ① **纯函数层**:开关两道的**优先级**(运维急停 > 用户开关 > 台账 > 取址),且每一道给具名码;
* ② **路由层(夹具,红→绿)**:真的把 `deviceWebRoutes` 挂到一个最小 Fastify 上打请求 ——
* 开关关 ⇒ 被拒(并给**新的**拒绝码,⛔ 不与既有四个码混);开关开 ⇒ 放行到代理腿;
* `DSHS_DEVICE_WEB=0` ⇒ **即使用户开关开着也被拒**(这条是 S5 改口径后的关键回归面);
* ③ **用户态写口**:开关读写**只碰自己那行**、变更**落审计**、停发端点**请求体没有 network 字段**
* (有它 = 把"能改别人的设备"做回来了,属结构性回归)。
*
* ⚠️ 夹具**不起真服务、不连网、不读真 DB**:`app.db` 是一个最小桩,只实现本文件用到的四个方法。
* 运行:`node --test test/device-web-switch.test.mjs`(已登记进 `package.json` 的 test/verify)。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import Fastify from 'fastify'
import {
deviceWebDecision,
deviceWebPlatformGate,
deviceWebSwitchOn,
indexRelayStatus,
resolveDeviceReachability,
switchRefusal,
} from '../lib/net/relay-endpoints.js'
import { deviceWebRoutes } from '../lib/web/routes/device-web.js'
import { sessionRoutes } from '../lib/web/routes/sessions.js'
import { hashSessionToken } from '../lib/web/auth.js'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
const USER = 'u-1'
const NET = `u:${USER}`
const HOST = 'd-u-1-aaaaaaaa'
const SID = 'sid-token'
/** 造一份"设备在线且已声明端口"的取址结果。 */
function reachable(hostId = HOST) {
const index = indexRelayStatus({
endpoints: [{ hostId, network: NET, port: 20090, localPort: 42000, online: true }],
})
return resolveDeviceReachability(index, { network: NET, hostId })
}
/** 造一份"设备一个端口都没声明"的取址结果(= relay 快照里没有它)。 */
function unreachable(hostId = HOST) {
return resolveDeviceReachability(indexRelayStatus({ endpoints: [] }), { network: NET, hostId })
}
// ── ① 纯函数层:两道开关的优先级与具名码 ──────────────────────────────────────
test('switchRefusal:急停压过用户开关(运维说关就是关)', () => {
const r = switchRefusal(false, true)
assert.equal(r?.reason, 'switch-off')
assert.match(r.detail, /DSHS_DEVICE_WEB=0/u)
})
test('switchRefusal:平台不拦但用户没开 ⇒ user-switch-off(⛔ 与 switch-off 不同码)', () => {
const r = switchRefusal(true, false)
assert.equal(r?.reason, 'user-switch-off')
assert.equal(switchRefusal(true, true), undefined)
})
test('deviceWebPlatformGate:只有字面量 0 才算急停;未设/1/其它写法都交给用户开关', () => {
assert.equal(deviceWebPlatformGate({ DSHS_DEVICE_WEB: '0' }).forceOff, true)
assert.equal(deviceWebPlatformGate({ DSHS_DEVICE_WEB: ' 0 ' }).forceOff, true)
for (const v of ['1', '', undefined, 'true', 'yes', 'off', 'false']) {
assert.equal(deviceWebPlatformGate({ DSHS_DEVICE_WEB: v }).forceOff, false, String(v))
}
assert.equal(deviceWebPlatformGate({}).forceOff, false)
// ⚠️ 旧入口仍可用:语义 = "平台没强制关",⛔ 不再等于"入口可用"。
assert.equal(deviceWebSwitchOn({}), true)
assert.equal(deviceWebSwitchOn({ DSHS_DEVICE_WEB: '0' }), false)
})
test('deviceWebDecision:用户开关缺省(未传)⇒ 一律当关(⛔ 漏传不许默认放行)', () => {
const d = deviceWebDecision({
enabled: true,
ledger: { status: 'active', kind: 'desktop' },
hostId: HOST,
resolved: reachable(),
})
assert.equal(d.ok, false)
assert.equal(d.reason, 'user-switch-off')
assert.equal('address' in d, false)
})
test('deviceWebDecision:五道闸全过 ⇒ ok;用户开关关 ⇒ 在台账闸之前就拒(顺序即优先级)', () => {
const off = deviceWebDecision({
enabled: true,
userEnabled: false,
// 台账刻意也错:若顺序被调换,这个用例会拿到 device-unknown ⇒ 立刻红。
ledger: undefined,
hostId: HOST,
resolved: reachable(),
})
assert.equal(off.reason, 'user-switch-off')
const on = deviceWebDecision({
enabled: true,
userEnabled: true,
ledger: { status: 'active', kind: 'desktop' },
hostId: HOST,
resolved: reachable(),
})
assert.equal(on.ok, true)
assert.equal(on.address, '127.0.0.1:42000')
})
test('deviceWebDecision:急停 + 用户开 ⇒ 仍是 switch-off(S5 验收面之一)', () => {
const d = deviceWebDecision({
enabled: false,
userEnabled: true,
ledger: { status: 'active', kind: 'desktop' },
hostId: HOST,
resolved: reachable(),
})
assert.equal(d.ok, false)
assert.equal(d.reason, 'switch-off')
})
test('deviceWebDecision:开关开着也⛔ 不能少台账那三道闸(回归面)', () => {
const base = { enabled: true, userEnabled: true, hostId: HOST, resolved: reachable() }
assert.equal(deviceWebDecision({ ...base, ledger: undefined }).reason, 'device-unknown')
assert.equal(deviceWebDecision({ ...base, ledger: { status: 'revoked', kind: 'desktop' } }).reason, 'device-revoked')
assert.equal(deviceWebDecision({ ...base, ledger: { status: 'active', kind: 'instance' } }).reason, 'device-not-desktop')
const far = deviceWebDecision({
enabled: true,
userEnabled: true,
ledger: { status: 'active', kind: 'desktop' },
hostId: HOST,
resolved: unreachable(),
})
assert.equal(far.reason, 'device-unreachable')
})
// ── ② 路由层夹具(红→绿):真打 HTTP,两条腿 ────────────────────────────────
/**
* 最小夹具:只装「设备入口 HTTP 腿」+「用户态会话/设备路由」,`app.db` 是桩。
*
* `proxied` 收集被转发到设备的那次调用 —— 它是"放行"的**可判证据**(⛔ 不看状态码就断言"通了")。
*/
function makeDb(state) {
return {
findSessionWithUser: async (hash) =>
hash === COOKIE_HASH ? { expiresAt: Date.now() + 60_000, user: { id: USER, role: 'active' } } : undefined,
findOverlayDevice: async (network, hostId) =>
network === NET && hostId === HOST ? { status: state.deviceStatus, kind: 'desktop' } : undefined,
listOverlayDevices: async () => [
{ hostId: HOST, kind: 'desktop', status: state.deviceStatus, leaseExpiresAt: 1, grantIssuedAt: 2, grantRenewals: 3, createdAt: 4, updatedAt: 5 },
],
getDeviceWebEnabled: async () => state.userEnabled,
setDeviceWebEnabled: async (_userId, enabled) => {
state.userEnabled = enabled
state.writes.push(['switch', enabled])
return true
},
setOverlayDeviceStatus: async (network, hostId, status) => {
state.deviceStatus = status
state.writes.push(['revoke', network, hostId, status])
return network === NET && hostId === HOST
},
audit: async (actor, action, detail) => state.audits.push([actor, action, detail]),
}
}
async function makeApp(state) {
const app = Fastify()
app.decorate('db', makeDb(state))
app.decorate('imInstanceTokens', undefined)
// 设备入口 HTTP 腿:真挂上去(⛔ 不改成"只测纯函数"——那样开关到底有没有接进路由就查不到了)。
await app.register(deviceWebRoutes, { resolve: ({ hostId }) => (state.online ? reachable(hostId) : unreachable(hostId)) })
await app.register(sessionRoutes, { resolve: ({ hostId }) => (state.online ? reachable(hostId) : unreachable(hostId)) })
await app.ready()
return app
}
/** 夹具里 `sid` cookie 的哈希 —— 与桩里的 `hashed:<token>` 判据配套(⛔ 不引入真 hash 依赖)。 */
const COOKIE = `sid=${SID}`
/** 🔴 桩必须用**真**哈希判据(`hashSessionToken`),⛔ 不许写死一个串 ——
* 否则夹具会在"cookie 根本没被解析"时也放行,测出来的绿是假的。 */
const COOKIE_HASH = hashSessionToken(SID)
const PATH = `/u/${USER}/desk/${HOST}/`
function newState(over = {}) {
return { userEnabled: false, deviceStatus: 'active', online: true, writes: [], audits: [], ...over }
}
test('夹具:用户开关关 ⇒ 设备入口被拒 403 user-switch-off(具名码,⛔ 不与既有四码混)', async () => {
const app = await makeApp(newState({ userEnabled: false }))
const res = await app.inject({ method: 'GET', url: PATH, headers: { cookie: COOKIE } })
assert.equal(res.statusCode, 403)
assert.equal(res.json().error, 'user-switch-off')
await app.close()
})
test('夹具:用户开关开 ⇒ 过了判定闸(⛔ 不再回策略拒绝码;本夹具没有真设备 ⇒ 代理腿必然打不通)', async () => {
const app = await makeApp(newState({ userEnabled: true }))
const res = await app.inject({ method: 'GET', url: PATH, headers: { cookie: COOKIE } })
// 判据写法:**403/404 意味着"被闸拦住了"** —— 而开关开着时不该再出现这两种。
// (本条夹具没有真的设备上游,代理腿打不通是正常的 ⇒ ⛔ 不硬编码 200。)
assert.notEqual(res.statusCode, 403, res.payload)
assert.notEqual(res.statusCode, 404, res.payload)
await app.close()
})
/**
* 🔴 **急停那条必须单独跑一个进程**(`test/fixtures/device-switch-forceoff.mjs`)。
*
* 原因:路由侧读的是 `process.env`,而 ESM 模块**只求值一次** ⇒ 在同一个进程里
* `process.env.DSHS_DEVICE_WEB='0'` 之后再 import,也拿不到那个值(这是 2026-09-27 实测踩到的,
* 当时夹具假绿:急停分支压根没被走到)。子进程 + 环境变量 = 与生产 systemd 完全同构的验法。
*/
test('夹具(独立进程):DSHS_DEVICE_WEB=0 ⇒ 用户开关开着也被拒,码 = switch-off', () => {
const out = spawnSync(process.execPath, [join(ROOT, 'test', 'fixtures', 'device-switch-forceoff.mjs')], {
env: { ...process.env, DSHS_DEVICE_WEB: '0' },
encoding: 'utf8',
})
assert.equal(out.status, 0, `子夹具失败:\n${out.stdout}\n${out.stderr}`)
assert.match(out.stdout, /FORCE-OFF-OK/u)
})
test('夹具(独立进程):不设 DSHS_DEVICE_WEB ⇒ 同一个子夹具**必须**失败(证明上一条不是恒绿)', () => {
const env = { ...process.env }
delete env.DSHS_DEVICE_WEB
const out = spawnSync(process.execPath, [join(ROOT, 'test', 'fixtures', 'device-switch-forceoff.mjs')], {
env,
encoding: 'utf8',
})
assert.notEqual(out.status, 0, `未设开关时子夹具不该通过:\n${out.stdout}\n${out.stderr}`)
})
test('夹具:无会话 ⇒ 401(开关怎么设都进不来;S5 ⛔ 不动这条路)', async () => {
const app = await makeApp(newState({ userEnabled: true }))
const res = await app.inject({ method: 'GET', url: PATH })
assert.equal(res.statusCode, 401)
await app.close()
})
test('夹具:别人的设备路径 ⇒ 403(路径用户 ≠ 登录用户,结构上到不了)', async () => {
const app = await makeApp(newState({ userEnabled: true }))
const res = await app.inject({ method: 'GET', url: `/u/u-2/desk/${HOST}/`, headers: { cookie: COOKIE } })
assert.equal(res.statusCode, 403)
await app.close()
})
test('夹具:GET /api/overlay/device-web 回"谁在拦"(急停时 platformGate=force-off)', async () => {
const app = await makeApp(newState({ userEnabled: true }))
const on = await app.inject({ method: 'GET', url: '/api/overlay/device-web', headers: { cookie: COOKIE } })
assert.deepEqual(
{ u: on.json().userEnabled, g: on.json().platformGate, e: on.json().effective },
{ u: true, g: 'open', e: true },
)
await app.close()
})
test('夹具:POST /api/overlay/device-web 改开关 + 落审计(谁/改成什么)', async () => {
const state = newState({ userEnabled: false })
const app = await makeApp(state)
const res = await app.inject({
method: 'POST',
url: '/api/overlay/device-web',
headers: { cookie: COOKIE, 'content-type': 'application/json' },
payload: { enabled: true },
})
assert.equal(res.statusCode, 200)
assert.equal(res.json().userEnabled, true)
assert.deepEqual(state.writes, [['switch', true]])
assert.equal(state.audits.length, 1)
assert.equal(state.audits[0][0], USER)
assert.equal(state.audits[0][1], 'device_web_switch')
assert.match(state.audits[0][2], /"enabled":true/u)
await app.close()
})
test('夹具:POST /api/overlay/device-web 的 body 非法 ⇒ 400(⛔ 不静默改)', async () => {
const app = await makeApp(newState())
for (const payload of [{}, { enabled: 'yes' }, { enabled: 1 }]) {
const res = await app.inject({
method: 'POST',
url: '/api/overlay/device-web',
headers: { cookie: COOKIE, 'content-type': 'application/json' },
payload,
})
assert.equal(res.statusCode, 400, JSON.stringify(payload))
}
await app.close()
})
test('夹具:GET /api/overlay/my-devices 投影 relay 在线态(本设备在线 ⇒ 端口 20090)', async () => {
const app = await makeApp(newState({ online: true }))
const res = await app.inject({ method: 'GET', url: '/api/overlay/my-devices', headers: { cookie: COOKIE } })
const d = res.json().devices[0]
assert.equal(d.hostId, HOST)
assert.equal(d.relayOnline, true)
assert.deepEqual(d.declaredPorts, [20090])
// ⛔ 投影里不许出现 relay 的回环落点(那是多余的拓扑面)
assert.equal('localPort' in d, false)
assert.equal(JSON.stringify(d).includes('42000'), false)
await app.close()
})
test('夹具:GET /api/overlay/my-devices 在 relay 查不到时 ⇒ relayOnline=false(⛔ 不编端口)', async () => {
const app = await makeApp(newState({ online: false }))
const res = await app.inject({ method: 'GET', url: '/api/overlay/my-devices', headers: { cookie: COOKIE } })
const d = res.json().devices[0]
assert.equal(d.relayOnline, false)
assert.deepEqual(d.declaredPorts, [])
await app.close()
})
test('夹具:停发自己的设备 ⇒ 走同一份台账语义 + 落审计', async () => {
// ⚠️ 本用例刻意从"开关**开着**"起步:否则复验阶段拿到的是 `user-switch-off`(第一道闸),
// 就测不到"停发之后台账闸真的生效"这件事。
const state = newState({ userEnabled: true })
const app = await makeApp(state)
const res = await app.inject({
method: 'POST',
url: '/api/overlay/my-devices/revoke',
headers: { cookie: COOKIE, 'content-type': 'application/json' },
payload: { hostId: HOST },
})
assert.equal(res.statusCode, 200)
assert.equal(res.json().status, 'revoked')
assert.deepEqual(state.writes, [['revoke', NET, HOST, 'revoked']])
assert.equal(state.audits[0][1], 'overlay_device_revoked')
// 停发后:开关开着也进不来(台账闸)
const after = await app.inject({ method: 'GET', url: PATH, headers: { cookie: COOKIE } })
assert.equal(after.statusCode, 403)
assert.equal(after.json().error, 'device-revoked')
await app.close()
})
test('夹具:停发一台不在我名下的设备 ⇒ 404(⛔ 不改别人的行)', async () => {
const state = newState()
const app = await makeApp(state)
const res = await app.inject({
method: 'POST',
url: '/api/overlay/my-devices/revoke',
headers: { cookie: COOKIE, 'content-type': 'application/json' },
payload: { hostId: 'd-someone-else-bbbbbbbb' },
})
assert.equal(res.statusCode, 404)
assert.equal(res.json().error, 'device-not-found')
assert.deepEqual(state.writes, [])
await app.close()
})
test('夹具:停发 body 缺 hostId ⇒ 400', async () => {
const app = await makeApp(newState())
const res = await app.inject({
method: 'POST',
url: '/api/overlay/my-devices/revoke',
headers: { cookie: COOKIE, 'content-type': 'application/json' },
payload: {},
})
assert.equal(res.statusCode, 400)
await app.close()
})
// ── ③ 源钉:结构上不许回退的地方 ─────────────────────────────────────────────
test('源钉:设备入口判定里**必须**读用户自己的开关(⛔ 只读 env 就是没做)', () => {
const src = read('src/web/routes/device-web.ts')
const decide = src.slice(src.indexOf('async function decideFor'), src.indexOf('function httpStatusOf'))
assert.match(decide, /getDeviceWebEnabled\(userId\)/u, 'decideFor 必须读 users.device_web_enabled')
assert.match(decide, /deviceWebPlatformGate\(\)/u, '平台闸必须走唯一读法,⛔ 别自己 parse env')
// ⛔ 旧的"只读 env 一个布尔"写法不许回来
assert.equal(/deviceWebSwitchOn\(\)/u.test(src), false, '⛔ 判定链里不许再用 deviceWebSwitchOn()')
})
test('源钉:开关写口只写自己那行(userId 来自 request.user),且落审计', () => {
const src = read('src/web/routes/sessions.ts')
const post = src.slice(src.indexOf("app.post('/api/overlay/device-web'"))
const head = post.slice(0, post.indexOf('app.post(', 10))
assert.match(head, /setDeviceWebEnabled\(user\.id/u, '写开关必须只写 request.user.id 那一行')
assert.match(head, /app\.db\.audit\(/u, '开关变更必须落审计')
assert.match(head, /requireAuth/u, '开关写口必须 requireAuth')
})
test('源钉:停发端点**请求体没有 network 字段**(范围只能由自己的网名算出来)', () => {
const src = read('src/web/routes/sessions.ts')
const post = src.slice(src.indexOf("app.post('/api/overlay/my-devices/revoke'"))
const head = post.slice(0, post.indexOf('app.post(', 10))
assert.equal(/body\?\.network/u.test(head), false, '⛔ 停发端点不许从请求体读 network')
assert.equal(/body\.network/u.test(head), false, '⛔ 停发端点不许从请求体读 network')
assert.match(head, /tenantNetworkOf\(user\.id\)/u, '网名必须由自己的 userId 推出')
assert.match(head, /app\.db\.audit\(/u, '停发必须落审计')
})
test('源钉:门户里「我的设备」段与开关控件都在(⛔ 不能只加服务端)', () => {
const html = read('web/portal.html')
assert.match(html, /#\/devices/u, '必须有 devices 页入口')
assert.match(html, /api\/overlay\/device-web/u, '开关必须真调开关端点')
assert.match(html, /api\/overlay\/my-devices/u, '「我的设备」必须读既有列表端点')
assert.match(html, /api\/overlay\/my-devices\/revoke/u, '必须能停发')
assert.match(html, /id="devSwInput"/u, '开关控件必须在')
assert.match(html, /开启后,用手机登录同一账号/u, '必须有一句话说明它做什么')
// 普通用户不被自动送走的那条例外必须在(否则开关对唯一需要它的人不可见)
assert.match(html, /parseHash\(\) !== 'devices'/u, "普通用户访问 #/devices 时不许被 /api/dsh/enter 送走")
})
test('源钉:v18 迁移存在且**默认关**(改默认值 = 静默放宽,必须显式红)', () => {
const src = read('src/db/schema.ts')
assert.match(src, /ADD COLUMN device_web_enabled INTEGER NOT NULL DEFAULT 0/u, '开关列必须 DEFAULT 0')
assert.match(src, /version: 18/u, 'v18 必须登记进 MIGRATIONS')
const adapter = read('src/db/adapter.ts')
assert.match(adapter, /getDeviceWebEnabled/u)
assert.match(adapter, /setDeviceWebEnabled/u)
})