/** 针对独立测试服务验证默认权限、远控回执与更新时机;不运行系统指令。 */ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const base = process.env.VERIFY_BASE_URL ?? 'http://127.0.0.1:4199'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); let passed = 0; async function api(route, token, body, method = body === undefined ? 'GET' : 'POST') { const res = await fetch(`${base}/api${route}`, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), }); return { status: res.status, ...(await res.json()) }; } function check(name, value) { assert.ok(value, name); passed++; console.log(`✓ ${name}`); } const admin = (await api('/auth/login', null, { username: 'admin', password: 'admin123' })).data.token; const teacher = (await api('/auth/login', null, { username: 'teacher1', password: 'teacher123' })).data.token; const classLogin = await api('/auth/class-login', null, { code: 'G101', password: '123456' }); const classToken = classLogin.data.token; const groups = (await api('/permissions/groups', admin)).data; const teacherDefault = groups.find((g) => g.defaultRole === 'TEACHER'); const adminDefault = groups.find((g) => g.defaultRole === 'ADMIN'); const key = `api-regression-${Date.now()}`; let deviceId, scheduleId, staffDeviceId, foreignClassId; const version = `99.0.0-regression-${Date.now()}`; const releaseDir = path.join(root, 'packages/data/client-releases', version); const releaseName = `ClassHelper-${version}-x64-client-setup.exe`; try { check('只有教师默认组,管理员没有可编辑默认组', teacherDefault && !adminDefault); await api(`/permissions/groups/${teacherDefault.id}`, admin, { name: '回归教师默认组', keys: [] }, 'PATCH'); let mine = await api('/permissions/me', teacher); check('未分组教师使用数据库默认组,空组真正收回权限', mine.data.permissions.length === 0); check('默认组收权后路由实时拒绝', (await api('/clients/devices', teacher)).status === 403); await api( `/permissions/groups/${teacherDefault.id}`, admin, { keys: ['client:read', 'client:manage', 'client:update-push', 'remote:control', 'class:read'] }, 'PATCH', ); mine = await api('/permissions/me', teacher); check('默认组改名后继续生效,可赋予远控与推送权限', mine.data.permissions.includes('remote:control')); check('拥有推送权限的教师可读取安装包列表', (await api('/update/client/releases', teacher)).status === 200); mine = await api('/permissions/me', admin); check( '管理员固定全集,不受教师模板修改影响', mine.data.permissions.includes('teacher:manage') && mine.data.permissions.includes('client:update'), ); const beat = await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key, name: '回归设备', clientVersion: '1.0.0', platform: 'win32-x64', autoUpdate: false, }); deviceId = beat.data.deviceId; check('班级端心跳返回统一响应与设备编号', beat.success && deviceId); let devices = (await api('/clients/devices', admin)).data; check( '设备是否接受自动更新与客户端真实配置一致', devices.find((d) => d.id === deviceId)?.autoUpdate === false, ); const cmd = await api('/clients/commands', admin, { deviceIds: [deviceId], kind: 'lock', note: '回归,不执行真实锁屏', }); check('指令成功入队', cmd.data.succeeded === 1); const received = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data; check('下次心跳带回待执行指令', received.command?.kind === 'lock'); const legacy = await api('/clients/heartbeat', classToken, { deviceKey: key }); check( '兼容旧客户端读取顶层指令', legacy.command?.id === received.command.id && legacy.data.command.id === legacy.command.id, ); const wrong = await api(`/clients/commands/${received.command.id}/ack`, classToken, { deviceKey: 'wrong-device-key', ok: true, }); check('回执拒绝其他设备身份', wrong.status === 403); await api(`/clients/commands/${received.command.id}/ack`, classToken, { agentProtocol: 2, deviceKey: key, ok: false, result: '模拟系统拒绝', }); await api(`/clients/commands/${received.command.id}/ack`, classToken, { agentProtocol: 2, deviceKey: key, ok: true, result: '不应覆盖', }); devices = (await api('/clients/devices', admin)).data; check( '回执幂等并在设备页保留失败原因', devices.find((d) => d.id === deviceId)?.lastCommand?.result.includes('模拟系统拒绝'), ); fs.mkdirSync(releaseDir, { recursive: true }); fs.writeFileSync(path.join(releaseDir, releaseName), '用于下载校验的测试载荷,不是安装器'); const registered = await api('/update/client/releases', admin, { version, fileName: releaseName }); check('本地缓存安装包可登记', registered.status === 201); const foreignClass = (await api('/classes', admin, { grade: '高一', enrollmentYear: 2035, classIndex: 99 })) .data; foreignClassId = foreignClass.id; const otherClassToken = ( await api('/auth/class-login', null, { code: foreignClass.code, password: '123456' }) ).data.token; staffDeviceId = ( await api('/clients/heartbeat', otherClassToken, { agentProtocol: 2, deviceKey: `${key}-other-class` }) ).data.deviceId; check( '管理员个人会话不能冒充设备心跳', (await api('/clients/heartbeat', admin, { agentProtocol: 2, deviceKey: `${key}-staff` })).status === 403, ); const deniedCommand = await api('/clients/commands', teacher, { deviceIds: [staffDeviceId], kind: 'lock' }); check('教师获远控权限后仍不能操作班级范围外设备', deniedCommand.status === 403); check( '教师获更新权限后不能越过班级范围推送', (await api('/clients/updates', teacher, { deviceIds: [staffDeviceId], version, mode: 'immediate' })) .status === 400, ); check( '教师获设备管理权限后不能删除范围外设备', (await api(`/clients/devices/${staffDeviceId}`, teacher, undefined, 'DELETE')).status === 404, ); let pushed = await api('/clients/updates', teacher, { deviceIds: [deviceId], version, mode: 'after-school', afterSchoolTime: '23:45', }); check('教师获授权后可推送放学后更新', pushed.status === 200); let scheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data; check( '更新策略与下载信息随心跳返回', scheduled.updateMode === 'after-school' && scheduled.update?.sha256?.length === 64 && new Date(scheduled.updateNotBefore).getHours() === 23, ); const legacyScheduled = await api('/clients/heartbeat', classToken, { deviceKey: key }); const downloaded = await fetch(`${base}${scheduled.update.url}`, { headers: { authorization: `Bearer ${classToken}` }, }); const downloadedBytes = Buffer.from(await downloaded.arrayBuffer()); check( '班级端可下载安装包,文件长度与 SHA256 匹配', downloaded.ok && downloadedBytes.length === scheduled.update.size && crypto.createHash('sha256').update(downloadedBytes).digest('hex') === scheduled.update.sha256, ); check( '旧客户端放学前不拿到安装包', Date.now() >= Date.parse(scheduled.updateNotBefore) || legacyScheduled.update === null, ); await api('/clients/updates', admin, { deviceIds: [deviceId], version, mode: 'immediate' }); scheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data; check( '同一版本可从延后改成立即安装', scheduled.updateMode === 'immediate' && scheduled.updateNotBefore === null, ); check( '非法放学时间被拒绝', ( await api('/clients/updates', admin, { deviceIds: [deviceId], version, mode: 'after-school', afterSchoolTime: '29:99', }) ).status === 422, ); const testClassId = classLogin.data.user.classId; const courses = (await api(`/courses?classId=${testClassId}`, admin)).data; const schedule = await api('/schedules', admin, { classId: testClassId, courseId: courses[0].id, dayOfWeek: new Date().getDay() || 7, startTime: '23:58', endTime: '23:59', weekStart: 1, weekEnd: 20, }); scheduleId = schedule.data?.id; check('创建放学时间回归课表', schedule.status === 201 && scheduleId); await api('/clients/updates', admin, { all: true, version, mode: 'after-school', afterSchoolTime: '00:01', }); const allScheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })) .data; check( '全部设备推送仍按各班当天最后一节课计算放学时间', new Date(allScheduled.updateNotBefore).getHours() === 23 && new Date(allScheduled.updateNotBefore).getMinutes() === 59, ); await api('/clients/updates', admin, { deviceIds: [deviceId], clear: true }); scheduled = (await api('/clients/heartbeat', classToken, { agentProtocol: 2, deviceKey: key })).data; check( '取消推送会清除目标版本与时间', scheduled.updateTargetVersion === null && scheduled.updateNotBefore === null && scheduled.update === null, ); check('退役联动接口返回 404', (await api('/integrations/classisland/status', admin)).status === 404); check( '退役通知渠道接口返回 404', (await api(`/classes/${classLogin.data.user.classId}/notification-channel`, classToken)).status === 404, ); } finally { await api( `/permissions/groups/${teacherDefault.id}`, admin, { name: teacherDefault.name, keys: teacherDefault.keys }, 'PATCH', ); if (deviceId) await api(`/clients/devices/${deviceId}`, admin, undefined, 'DELETE'); if (staffDeviceId) await api(`/clients/devices/${staffDeviceId}`, admin, undefined, 'DELETE'); if (foreignClassId) await api(`/classes/${foreignClassId}`, admin, undefined, 'DELETE'); if (scheduleId) await api(`/schedules/${scheduleId}`, admin, undefined, 'DELETE'); await api(`/update/client/releases/${version}`, admin, undefined, 'DELETE'); if (!path.resolve(releaseDir).startsWith(path.resolve(root, 'packages/data/client-releases') + path.sep)) throw new Error('拒绝清理交付目录外的路径'); fs.rmSync(releaseDir, { recursive: true, force: true }); } console.log(`客户端 API 回归:${passed}/${passed} 项通过`);