Files
ClassHelper/scripts/verify-clients-api.mjs
T

234 lines
11 KiB
JavaScript
Raw Normal View History

/** 针对独立测试服务验证默认权限、远控回执与更新时机;不运行系统指令。 */
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} 项通过`);