419 lines
19 KiB
JavaScript
419 lines
19 KiB
JavaScript
/**
|
||
* 手机接入线 · **用户级「手机访问」开关**(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)
|
||
})
|