Files
LoveChengke 66be3a63b1 feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
2026-10-31 09:40:23 +08:00

517 lines
23 KiB
TypeScript
Raw Permalink 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.
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { io as connect, type Socket } from 'socket.io-client';
import {
PERMISSION_KEYS,
PERMISSION_META,
SOCKET_CHANNELS,
SOCKET_ROOMS,
SOCKET_EVENTS,
parsePermissionScope,
} from '@classhelper/shared';
import { prisma } from '../src/lib/db.js';
import { createApp } from '../src/app.js';
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
import { ensureDefaultGroups } from '../src/lib/permissions.js';
import { classIdsFor } from '../src/lib/authz/index.js';
import { initRealtime } from '../src/realtime/socket.js';
import { emitToClass } from '../src/realtime/bus.js';
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
if (!process.env.DATABASE_URL?.includes('authz-verification'))
throw new Error('只能在授权验收临时数据库运行');
let count = 0;
const check = (name: string) => console.log(`通过 ${++count}:${name}`);
async function account(name: string, role: TokenPayload['role'] = 'TEACHER'): Promise<TokenPayload> {
const row = await prisma.user.create({
data: { username: name, name, role, passwordHash: '隔离夹具,不使用真实账号' },
});
return { sub: row.id, username: name, name, role, classId: null };
}
const admin = await account('authz-admin', 'ADMIN'),
aTeacher = await account('authz-A'),
bTeacher = await account('authz-B'),
unrelated = await account('authz-C'),
lead = await account('authz-grade'),
division = await account('authz-division');
await ensureDefaultGroups();
const cls = async (name: string, grade: string, teacherId: string, index: number) =>
prisma.class.create({
data: {
name,
grade,
teacherId,
classIndex: index,
enrollmentYear: 2026,
code: `AUTHZ${index}`,
passwordHash: '隔离',
},
});
const A = await cls('授权A', '高一', aTeacher.sub, 1),
B = await cls('授权B', '高一', bTeacher.sub, 2),
_C = await cls('授权C', '高二', bTeacher.sub, 3);
const course = async (classId: string, name: string, teacherId: string) =>
prisma.course.create({ data: { classId, name, teacherId } });
const mathB = await course(B.id, '数学', aTeacher.sub),
chineseB = await course(B.id, '语文', bTeacher.sub),
mathA = await course(A.id, '数学', aTeacher.sub);
const studentA = await prisma.student.create({ data: { studentNo: 'AUTHZA', name: '临时A', classId: A.id } }),
studentB = await prisma.student.create({ data: { studentNo: 'AUTHZB', name: '临时B', classId: B.id } });
const device = (c: typeof A): TokenPayload => ({
sub: c.id,
username: c.code,
name: c.name,
role: 'CLASS_DEVICE',
classId: c.id,
classSession: true,
classCode: c.code,
});
const aDevice = device(A),
bDevice = device(B);
const server = createServer(createApp()),
realtime = initRealtime(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`,
base = origin + '/api';
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
const res = await fetch(base + url, {
method,
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json = await res.json();
assert.equal(res.status, expected, `${method} ${url} ${JSON.stringify(json)}`);
return json.data;
}
async function group(name: string, keys: string[], scope = 'TEACHING', scopeValue = '') {
return prisma.permissionGroup.create({
data: { name, scope, scopeValue, items: { create: keys.map((key) => ({ key })) } },
});
}
async function assign(actor: TokenPayload, rows: { groupId: string; scope: string; scopeValue?: string }[]) {
await prisma.userPermissionGroup.deleteMany({ where: { userId: actor.sub } });
if (rows.length)
await prisma.userPermissionGroup.createMany({
data: rows.map((r) => ({ userId: actor.sub, ...r, scopeValue: r.scopeValue ?? '' })),
});
}
const sockets: Socket[] = [];
async function socket(actor: TokenPayload) {
const s = connect(origin, {
auth: { token: signToken(actor) },
transports: ['websocket'],
reconnection: false,
});
sockets.push(s);
const info = await new Promise<{ rooms: string[] }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('实时连接超时')), 5000);
s.once('connected', (data) => {
clearTimeout(timer);
resolve(data);
});
s.once('connect_error', reject);
});
return { s, rooms: info.rooms };
}
try {
// 旧残留管理员键全部装备给科任,管理员能力仍永久无效。
const old = await group('旧残留全能力', [...PERMISSION_KEYS], 'ALL');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const mine = await request('/permissions/me', aTeacher);
assert(
!mine.permissions.some((key: keyof typeof PERMISSION_META) => PERMISSION_META[key].kind === 'admin'),
);
assert.equal((await request('/permissions/me', admin)).permissions.length, PERMISSION_KEYS.length);
check('管理员全集;旧组残留管理员键对教师无效');
for (const [url, method, body] of [
[`/classes/${B.id}/class-account`, 'PATCH', { password: 'authz-test' }],
[`/classes/${B.id}/settings`, 'PATCH', { termWeeks: 12 }],
[`/classes/${B.id}/head-teacher`, 'PATCH', { teacherId: aTeacher.sub }],
[
`/classes/${B.id}/subject-teachers`,
'PUT',
{ assignments: [{ subjectName: '语文', teacherId: aTeacher.sub }] },
],
['/classes', 'POST', { grade: '高一', enrollmentYear: 2029, classIndex: 99 }],
['/teachers', 'POST', { username: 'intruder', name: '攻击', role: 'ADMIN' }],
[`/teachers/${admin.sub}`, 'PATCH', { role: 'TEACHER' }],
[`/teachers/${admin.sub}/reset-password`, 'POST', {}],
[`/teachers/${admin.sub}`, 'DELETE', undefined],
['/permissions/groups', 'POST', { name: '越权', keys: [] }],
['/system/settings', 'PUT', {}],
['/database/status', 'GET', undefined],
['/term', 'PUT', { weeks: [] }],
['/term/promotion', 'GET', undefined],
[
'/imports/table/commit',
'POST',
{ kind: 'classTeachers', fileName: 'authz.csv', contentBase64: '', mapping: {}, mode: 'append' },
],
] as const)
await request(url, aTeacher, method, body, 403);
assert.equal((await request(`/classes/${B.id}`, aTeacher)).code, undefined);
check('科任不能接管班级账号、学期设置、人员关系或组织写入口,班级码不泄漏');
await request(`/classes/${A.id}/class-account`, aTeacher, 'PATCH', { code: 'AUTHZA1' });
await request(`/classes/${A.id}/settings`, aTeacher, 'PATCH', { termWeeks: 12 });
await request(`/classes/${B.id}/class-account`, admin, 'PATCH', { code: 'AUTHZB1' });
check('本班班主任可改账号与学期设置;管理员可改其他班');
await request(`/teachers/${admin.sub}`, admin, 'PATCH', { role: 'TEACHER' }, 409);
await request(`/teachers/${admin.sub}`, admin, 'DELETE', undefined, 409);
check('最后一个管理员不可降级或删除');
const helper = await request(
'/teachers',
admin,
'POST',
{ username: 'authz-new', name: '新增教师', password: 'authz-pass' },
201,
);
await request(`/teachers/${helper.id}/reset-password`, admin, 'POST', { newPassword: 'authz-pass-2' });
await request(`/teachers/${helper.id}`, admin, 'DELETE');
check('管理员账号写入口正常且产生审计');
const readA = await group(
'A的关联读取',
[
'class:read',
'homework:read',
'notification:read',
'grade:read',
'dashboard:read',
'schedule:read',
'roster:read',
],
'HEAD',
);
const otherAll = await group('无关全校键', ['client:read'], 'ALL');
await assign(aTeacher, [
{ groupId: readA.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
for (const url of [
`/grades/ranking?classId=${B.id}&examName=测试`,
`/grades/exams?classId=${B.id}`,
`/grades/collective?classId=${B.id}`,
`/homeworks?classId=${B.id}`,
`/notifications?classId=${B.id}`,
])
await request(url, aTeacher, 'GET', undefined, 403);
assert.deepEqual(
(await request('/classes', aTeacher)).map((c: { id: string }) => c.id),
[A.id],
);
const dashboard = await request('/dashboard/summary', aTeacher);
assert.equal(dashboard.classCount, 1);
assert.equal((await classIdsFor({ ...aTeacher }, undefined as never)).length, 0);
check('按键独立计算,设备全校读不能扩大内容读取;无键范围为空');
await assign(unrelated, [{ groupId: otherAll.id, scope: 'ALL' }]);
for (const url of ['/homeworks', '/notifications', '/dashboard/summary', `/grades/exams?classId=${B.id}`])
await request(url, unrelated, 'GET', undefined, 403);
const onlyDashboard = await group('仅看板', ['dashboard:read'], 'ALL');
await assign(unrelated, [{ groupId: onlyDashboard.id, scope: 'ALL' }]);
const hidden = await request('/dashboard/summary', unrelated);
assert.equal(hidden.gradeCount, 0);
assert.equal(hidden.studentCount, 0);
assert.equal(hidden.sections.grades, false);
check('宽范围无关能力不授予读权限,看板各段独立受控');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const hw = await prisma.homework.create({
data: {
classId: B.id,
courseId: chineseB.id,
title: '同事语文',
content: '隔离测试',
createdBy: bTeacher.sub,
},
});
const grade = await prisma.grade.create({
data: { classId: B.id, courseId: chineseB.id, studentId: studentB.id, examName: '授权考试', score: 80 },
});
await request(`/homeworks/${hw.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(`/grades/${grade.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(
`/homeworks/${hw.id}/status`,
aTeacher,
'PATCH',
{ completed: true, studentId: studentB.id },
403,
);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'GET', undefined, 403);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'PATCH', { notSubmittedStudentIds: [] }, 403);
await request(`/grades/exams`, aTeacher, 'PUT', { classId: B.id, name: '授权考试', countInTotal: true });
// 年级/全校授权可配置考试口径;仅任教范围的科任不能。
await assign(aTeacher, [{ groupId: old.id, scope: 'TEACHING' }]);
await request(
`/grades/exams`,
aTeacher,
'PUT',
{ classId: B.id, name: '授权考试', countInTotal: false },
403,
);
await request(`/classes/${B.id}/student-grade-query`, aTeacher, 'PATCH', { enabled: false }, 403);
check('原/目标科目双校验;未交名单与完成状态需要科目关系;科任不能改考试配置');
const ownHw = await request(
'/homeworks',
aTeacher,
'POST',
{ classId: B.id, courseId: mathB.id, title: '本人数学', content: '隔离测试' },
201,
);
await request(`/homeworks/${ownHw.id}/status`, bDevice, 'PATCH', { completed: true });
check('科任可发布本科作业,班级端可维护本班作业');
const layoutBody = {
classId: A.id,
name: '本班时间表',
points: [{ name: '第一节', startTime: '08:00', endTime: '08:40', timeType: 'LESSON' }],
};
const layout = await request('/time-layouts', aTeacher, 'POST', layoutBody, 201);
const foreign = await request(
'/time-layouts',
admin,
'POST',
{ ...layoutBody, classId: B.id, name: '别班时间表' },
201,
);
for (const method of ['PUT', 'DELETE'])
await request(
`/time-layouts/${foreign.id}`,
aTeacher,
method,
method === 'PUT' ? { ...layoutBody, classId: A.id } : undefined,
403,
);
await request(`/time-layouts/${foreign.id}/clone`, aTeacher, 'POST', { name: '偷复制' }, 403);
await request('/time-layouts', aTeacher, 'POST', { ...layoutBody, classId: null }, 403);
await request('/term', aTeacher, 'PUT', { classId: B.id, weeks: [] }, 403);
await request('/term', aTeacher, 'PUT', { classId: A.id, weeks: [] });
await request(`/time-layouts/${layout.id}`, aTeacher, 'DELETE');
check('时间表按存储班级校验;本班可写、跨班与全局拒绝;班级周次要求班主任');
const csv = `学号,姓名,班级\nAUTHZSAFE,首行,${A.name}\nAUTHZBAD,末行,${B.name}`;
await request(
'/imports/table/commit',
aTeacher,
'POST',
{
kind: 'students',
fileName: 'authz.csv',
contentBase64: Buffer.from('\ufeff' + csv).toString('base64'),
mapping: { studentNo: '学号', name: '姓名', className: '班级' },
mode: 'append',
},
403,
);
assert.equal(await prisma.student.count({ where: { studentNo: 'AUTHZSAFE' } }), 0);
check('跨班学生导入预检全部范围,403 不留下首行数据');
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { scope: 'ALL' }, 400);
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { keys: ['teacher:manage'] }, 400);
await request(
'/permissions/groups',
admin,
'POST',
{ name: '非法年级', scope: 'GRADE', scopeValue: '不存在', keys: ['class:read'] },
400,
);
await request('/permissions/groups', admin, 'POST', { name: '非法能力', keys: ['permission:manage'] }, 400);
const legacyTemplate = await group('保留旧停用键', ['class:read', 'class:manage']);
const updatedLegacyTemplate = await request(`/permissions/groups/${legacyTemplate.id}`, admin, 'PATCH', {
keys: ['class:read', 'grade:read'],
});
assert(updatedLegacyTemplate.inactiveKeys.includes('class:manage'));
assert.equal(
await prisma.permissionGroupItem.count({ where: { groupId: legacyTemplate.id, key: 'class:manage' } }),
1,
);
await assign(unrelated, [{ groupId: legacyTemplate.id, scope: 'ALL' }]);
assert(!(await request('/permissions/me', unrelated)).permissions.includes('class:manage'));
check('默认模板固定 TEACHING;不可保存管理员能力或未知年级');
const scoped = await request(
'/permissions/groups',
admin,
'POST',
{ name: '分配级范围', scope: 'HEAD', keys: ['class:read', 'grade:read', 'class:account'] },
201,
);
await request(`/permissions/users/${lead.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高一' }],
});
await request(`/permissions/users/${division.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'DIVISION', scopeValue: '高二,高一' }],
});
assert.equal((await request('/classes', lead)).length, 2);
assert.equal((await request('/classes', division)).length, 3);
const effective = await request(`/permissions/users/${lead.sub}/effective`, admin);
assert.equal(effective.entries.find((e: { key: string }) => e.key === 'class:account').classCount, 0);
const leadPermissions = await request('/permissions/me', lead);
assert.deepEqual(leadPermissions.classScopes['grade:read'].sort(), [A.id, B.id].sort());
await request(`/grades/ranking?classId=${B.id}&examName=授权考试`, lead);
await request(`/permissions/groups/${scoped.id}`, admin, 'PATCH', { scope: 'ALL' });
assert.equal((await request('/classes', lead)).length, 2);
check('模板建议范围变更不影响已有分配;级长/部长范围与班主任关系独立');
const query = encodeURIComponent(
JSON.stringify([
{ groupId: scoped.id, scope: 'HEAD', scopeValue: '' },
{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高二' },
]),
);
const merged = await request(`/permissions/users/${aTeacher.sub}/effective?assignments=${query}`, admin);
assert.equal(merged.entries.find((e: { key: string }) => e.key === 'class:read').classCount, 2);
assert(merged.conflicts.length > 0);
await request(`/permissions/users/${admin.sub}/groups`, admin, 'PUT', { assignments: [] }, 400);
check('同模板多范围正确合并并预览冲突,管理员不可分配');
const beforeDefault = await request('/permissions/me', bTeacher);
await request(`/permissions/users/${bTeacher.sub}/groups`, admin, 'PUT', { assignments: [] });
assert.equal((await request('/permissions/me', bTeacher)).fromGroups, false);
assert(beforeDefault.permissions.length > 0);
check('空分配恢复默认,无隐式自动预选转换');
await request(`/classes/${B.id}/student-grade-query`, admin, 'PATCH', { enabled: false });
for (const url of [
`/grades/ranking?classId=${B.id}&examName=授权考试`,
`/grades/exams?classId=${B.id}`,
`/grades/student/${studentB.studentNo}`,
'/grades/my',
])
await request(url, bDevice, 'GET', undefined, 403);
assert.equal((await request(`/grades/collective?classId=${B.id}`, bDevice)).mode, 'collective');
await request(`/grades/collective?classId=${B.id}`, aDevice, 'GET', undefined, 403);
check('班级端关闭查询后拒绝个人/排名/原始列表,保留本班集体聚合');
const hb = {
deviceKey: 'authz-device-b',
name: '临时设备',
clientVersion: '1.0.0',
platform: 'win32',
agentProtocol: 2,
};
const d = await request('/clients/heartbeat', bDevice, 'POST', hb);
await request('/clients/heartbeat', aTeacher, 'POST', hb, 403);
await request('/clients/heartbeat', aDevice, 'POST', hb, 403);
const command = await prisma.clientCommand.create({
data: { deviceId: d.deviceId, kind: 'lock', createdBy: admin.sub, payload: '隔离假执行' },
});
for (const actor of [aTeacher, aDevice])
await request(
`/clients/commands/${command.id}/ack`,
actor,
'POST',
{ deviceKey: hb.deviceKey, ok: true },
403,
);
await request(`/clients/commands/${command.id}/ack`, bDevice, 'POST', {
deviceKey: hb.deviceKey,
ok: true,
result: '未执行任何系统操作',
});
await request(
'/clients/update-progress',
aTeacher,
'POST',
{
deviceKey: hb.deviceKey,
progress: {
version: '1.1.0',
stage: 'downloading',
downloadedBytes: 0,
totalBytes: 1,
percent: 0,
error: '',
},
},
403,
);
check('心跳/进度/回执只接受本班班级端,教师知道 deviceKey 仍不能冒用');
await request('/update/client/releases', admin, 'POST', { version: '..' }, 400);
await request('/update/client/download/1.0.0', unrelated, 'GET', undefined, 403);
check('更新路径拒绝 ..,下载要求班级端或设备读能力及范围');
const readOnly = await group('只读作业实时', ['homework:read'], 'HEAD');
await assign(aTeacher, [{ groupId: readOnly.id, scope: 'TEACHING' }]);
const previousSubjectSocket = await socket(aTeacher);
assert(previousSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
const disconnected = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('任课导入后旧订阅未断开')), 5000);
previousSubjectSocket.s.once('disconnect', () => {
clearTimeout(timer);
resolve();
});
});
await request('/imports/table/commit', admin, 'POST', {
kind: 'classTeachers',
fileName: 'relations.csv',
mapping: { className: '班级', subjectName: '科目', teacherNo: '工号', teacherName: '姓名', role: '角色' },
contentBase64: Buffer.from(
`\ufeff班级,科目,工号,姓名,角色\n${B.name},数学,${bTeacher.username},临时教师,科任`,
).toString('base64'),
mode: 'upsert',
});
await disconnected;
const updatedSubjectSocket = await socket(aTeacher);
assert(!updatedSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
check('任课导入变更后旧房间立即失效,重连按现行关系订阅');
await assign(aTeacher, [
{ groupId: readOnly.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
const { s, rooms } = await socket(aTeacher),
deviceSocket = await socket(aDevice);
assert(rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'grade')));
assert(SOCKET_CHANNELS.every((ch) => deviceSocket.rooms.includes(SOCKET_ROOMS.classChannel(A.id, ch))));
const join = await new Promise<{ ok: boolean }>((resolve) =>
s.emit('class:join', { classId: B.id, channels: ['homework'] }, resolve),
);
assert.equal(join.ok, false);
const seen: string[] = [];
s.on(SOCKET_EVENTS.gradeUpdated, () => seen.push('grade'));
s.on(SOCKET_EVENTS.homeworkNew, () => seen.push('homework'));
emitToClass(A.id, SOCKET_EVENTS.gradeUpdated, { classId: A.id });
emitToClass(B.id, SOCKET_EVENTS.homeworkNew, { id: 'foreign' });
emitToClass(A.id, SOCKET_EVENTS.homeworkNew, { id: 'own' });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.deepEqual(seen, ['homework']);
check('实时按读域与范围订阅,跨班 join 和跨域事件均拒绝');
const stale = signToken(admin);
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'TEACHER' } });
const staleSocket = connect(origin, {
auth: { token: stale },
transports: ['websocket'],
reconnection: false,
});
sockets.push(staleSocket);
const staleInfo = await new Promise<{ role: string }>((resolve) => staleSocket.once('connected', resolve));
assert.equal(staleInfo.role, 'TEACHER');
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'ADMIN' } });
check('Socket 与 HTTP 共用数据库身份加载,旧管理员 JWT 不保留角色');
const snapshot = await exportSnapshot(prisma, 'sqlite');
const legacy = structuredClone(snapshot);
for (const g of legacy.data.PermissionGroup)
if (g.id === readOnly.id) {
g.scope = 'SELF_CLASS';
g.scopeValue = 'HEAD';
}
for (const a of legacy.data.UserPermissionGroup) {
delete a.scope;
delete a.scopeValue;
}
await restoreSnapshot(prisma, 'sqlite', legacy);
assert.equal(
(
await prisma.userPermissionGroup.findFirstOrThrow({
where: { userId: aTeacher.sub, groupId: readOnly.id },
})
).scope,
'HEAD',
);
assert.equal(
(await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } })).scope,
'TEACHING',
);
assert.throws(() => parsePermissionScope('SELF_CLASS' as never, ''));
check('旧快照按模板回填分配、默认模板归一化、运行时拒绝旧范围枚举');
check(`授权攻击矩阵完成,夹具 ${studentA.id} / ${mathA.id} 均位于临时库`);
} finally {
for (const s of sockets) s.disconnect();
await new Promise<void>((resolve) => realtime.close(() => resolve()));
server.closeAllConnections();
if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
await prisma.$disconnect();
}