/** * 手机接入线 · **用户级「手机访问」开关**(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:` 判据配套(⛔ 不引入真 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) })