Merge current features with security hardening and automatic safe initialization
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { after, test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { UserRole } from "@prisma/client";
|
||||
import { render } from "@react-email/render";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
import {
|
||||
executeAlarmCommand,
|
||||
respondToAlarm,
|
||||
getPublicAlarm,
|
||||
getAlarmFeed,
|
||||
processAlarmDeadlines,
|
||||
importLegacyAlarms,
|
||||
assertAssignmentNotAlarmed,
|
||||
type AlarmActor,
|
||||
} from "../src/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "../src/lib/alarms/email";
|
||||
import { authorizeAlarmJob } from "../src/lib/alarms/job";
|
||||
|
||||
|
||||
import {
|
||||
berlinInstant,
|
||||
endOfAlarm,
|
||||
alarmLabel,
|
||||
} from "../src/lib/alarms/format";
|
||||
import { dateOnly } from "../src/lib/staff/calculations";
|
||||
|
||||
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||
if (
|
||||
db.hostname !== "127.0.0.1" ||
|
||||
db.port !== "5433" ||
|
||||
db.pathname !== "/kita_security_test"
|
||||
)
|
||||
throw new Error(
|
||||
"Alarmtests benötigen die isolierte Datenbank 127.0.0.1:5433/kita_security_test.",
|
||||
);
|
||||
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||
let testUser: { id: string; role: UserRole; kitaId: string; familyId: string } | null = null;
|
||||
const testSession = () => { if (!testUser) throw new Error('No test session'); return { user: testUser }; };
|
||||
mock.module('../src/lib/auth-utils.ts', { namedExports: {
|
||||
requireKitaSession: async () => testSession(),
|
||||
requireRole: async (roles: UserRole[]) => { const session = testSession(); if (!roles.includes(session.user.role)) throw new Error('Forbidden'); return session; },
|
||||
} });
|
||||
const loadAfterMocks = createRequire(resolve(process.cwd(), 'tests/notdienst-alarm.test.ts'));
|
||||
const { updateAssignmentAction } = loadAfterMocks('../src/app/dashboard/notdienst/plan/actions') as typeof import('../src/app/dashboard/notdienst/plan/actions');
|
||||
const { createNotdienstSwapRequest, acceptNotdienstSwapRequest } = loadAfterMocks('../src/app/dashboard/kalender/actions') as typeof import('../src/app/dashboard/kalender/actions');
|
||||
|
||||
const tenants: string[] = [];
|
||||
after(async () => {
|
||||
await prisma.kita.deleteMany({ where: { id: { in: tenants } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
const date = "2090-01-09",
|
||||
now = berlinInstant(date, 480);
|
||||
async function fixture() {
|
||||
const kita = await prisma.kita.create({
|
||||
data: { name: "Alarm-Testkita", slug: `alarm-test-${randomUUID()}` },
|
||||
});
|
||||
tenants.push(kita.id);
|
||||
const family = await prisma.family.create({
|
||||
data: { kitaId: kita.id, name: "Testfamilie" },
|
||||
});
|
||||
const otherFamily = await prisma.family.create({
|
||||
data: { kitaId: kita.id, name: "Anderer Haushalt" },
|
||||
});
|
||||
const makeUser = (role: UserRole, familyId?: string) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
role,
|
||||
familyId,
|
||||
email: `${randomUUID()}@alarm-test.invalid`,
|
||||
firstName: "Test",
|
||||
lastName: role,
|
||||
passwordHash: "test-only",
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const adminUser = await makeUser("ADMIN"),
|
||||
coordUser = await makeUser("KOORDINATOR"),
|
||||
staffUser = await makeUser("ERZIEHER"),
|
||||
p1 = await makeUser("ELTERN", family.id),
|
||||
p2 = await makeUser("ELTERN", family.id),
|
||||
outsider = await makeUser("ELTERN", otherFamily.id);
|
||||
const actor = (u: typeof p1): AlarmActor => ({
|
||||
id: u.id,
|
||||
kitaId: kita.id,
|
||||
role: u.role,
|
||||
});
|
||||
const admin = actor(adminUser),
|
||||
coord = actor(coordUser),
|
||||
staff = actor(staffUser),
|
||||
parent = actor(p1),
|
||||
otherParent = actor(outsider);
|
||||
const child = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
familyId: family.id,
|
||||
firstName: "Testkind",
|
||||
lastName: "Testfamilie",
|
||||
},
|
||||
});
|
||||
const educator = await prisma.educator.create({
|
||||
data: { kitaId: kita.id, firstName: "Interne", lastName: "Fachkraft" },
|
||||
});
|
||||
const plan = await prisma.notdienstPlan.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
year: 2090,
|
||||
month: 1,
|
||||
status: "PUBLISHED",
|
||||
publishedAt: now,
|
||||
createdById: admin.id,
|
||||
},
|
||||
});
|
||||
const assignment = await prisma.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId: kita.id,
|
||||
planId: plan.id,
|
||||
childId: child.id,
|
||||
date: dateOnly(date),
|
||||
},
|
||||
});
|
||||
const create = (
|
||||
data: Record<string, unknown> = {},
|
||||
who = admin,
|
||||
time = now,
|
||||
) =>
|
||||
executeAlarmCommand(
|
||||
who,
|
||||
"create",
|
||||
{ assignmentId: assignment.id, wholeDay: true, ...data },
|
||||
time,
|
||||
);
|
||||
const command = (
|
||||
name: string,
|
||||
alarmId: string,
|
||||
data: Record<string, unknown> = {},
|
||||
who = coord,
|
||||
time = now,
|
||||
) => executeAlarmCommand(who, name, { alarmId, ...data }, time);
|
||||
const recipients = async (alarmId: string) => {
|
||||
const rows = await prisma.notdienstAlarmRecipient.findMany({ where: { alarmId }, orderBy: { userId: "asc" } });
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({ where: { alarmId, kind: "REQUEST" } });
|
||||
return rows.map(row => ({ ...row, token: jobs.find(job => job.userId === row.userId)?.path.split("/").pop() ?? row.token }));
|
||||
};
|
||||
return {
|
||||
kita,
|
||||
family,
|
||||
otherFamily,
|
||||
adminUser,
|
||||
coordUser,
|
||||
admin,
|
||||
coord,
|
||||
staff,
|
||||
parent,
|
||||
otherParent,
|
||||
p1,
|
||||
p2,
|
||||
outsider,
|
||||
child,
|
||||
educator,
|
||||
plan,
|
||||
assignment,
|
||||
create,
|
||||
command,
|
||||
recipients,
|
||||
};
|
||||
}
|
||||
|
||||
test("Hinweise können nur vom eigenen Empfänger gelesen werden", async () => {
|
||||
const f = await fixture();
|
||||
const alarm = await f.create();
|
||||
const notice = await prisma.notdienstAlarmNotification.findFirstOrThrow({
|
||||
where: { alarmId: alarm.alarmId, userId: f.p1.id },
|
||||
});
|
||||
await executeAlarmCommand(
|
||||
f.otherParent,
|
||||
"readNotice",
|
||||
{ id: notice.id },
|
||||
now,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarmNotification.findUniqueOrThrow({
|
||||
where: { id: notice.id },
|
||||
})
|
||||
).readAt,
|
||||
null,
|
||||
);
|
||||
await executeAlarmCommand(f.parent, "readNotice", { id: notice.id }, now);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarmNotification.findUniqueOrThrow({
|
||||
where: { id: notice.id },
|
||||
})
|
||||
).readAt?.getTime(),
|
||||
now.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
test("Gesicherte Konten und fehlende Einwilligung verhindern Verwaltungszugriff", async () => {
|
||||
const f = await fixture();
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { privacyPolicyAcceptedAt: null },
|
||||
});
|
||||
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: {
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
emailVerifiedAt: new Date(),
|
||||
lockedUntil: new Date(Date.now() + 3600000),
|
||||
},
|
||||
});
|
||||
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||
const alarm = await f.create({}, f.coord);
|
||||
const token = (await f.recipients(alarm.alarmId!)).find(
|
||||
(r) => r.userId === f.p1.id,
|
||||
)!.token;
|
||||
await prisma.user.update({
|
||||
where: { id: f.p1.id },
|
||||
data: { lockedUntil: new Date(Date.now() + 3600000) },
|
||||
});
|
||||
assert.equal(await getPublicAlarm(token), null);
|
||||
await assert.rejects(respondToAlarm(token, true, now), /Ungültiger/);
|
||||
});
|
||||
|
||||
test("Aktive Alarme sperren manuelle Zuordnung sowie neue und bereits angefragte Tausche", async () => {
|
||||
const f = await fixture();
|
||||
const otherChild = await prisma.child.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
familyId: f.otherFamily.id,
|
||||
firstName: "Anderes",
|
||||
lastName: "Testkind",
|
||||
},
|
||||
});
|
||||
const otherAssignment = await prisma.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
planId: f.plan.id,
|
||||
childId: otherChild.id,
|
||||
date: dateOnly("2090-01-10"),
|
||||
},
|
||||
});
|
||||
const request = await prisma.notdienstSwapRequest.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
requesterUserId: f.outsider.id,
|
||||
requesterFamilyId: f.otherFamily.id,
|
||||
targetAssignmentId: f.assignment.id,
|
||||
offeredAssignmentId: otherAssignment.id,
|
||||
},
|
||||
});
|
||||
await f.create();
|
||||
function login(actor: AlarmActor, familyId: string) { testUser = { ...actor, familyId }; }
|
||||
try {
|
||||
login(f.admin, f.family.id);
|
||||
const manual = await updateAssignmentAction(f.plan.id, date, otherChild.id);
|
||||
assert.match(manual.error ?? "", /Alarm/);
|
||||
login(f.otherParent, f.otherFamily.id);
|
||||
const newSwap = await createNotdienstSwapRequest({
|
||||
targetAssignmentId: f.assignment.id,
|
||||
offeredAssignmentId: otherAssignment.id,
|
||||
});
|
||||
assert.match(newSwap.error ?? "", /Alarmierung/);
|
||||
login(f.parent, f.family.id);
|
||||
const accept = await acceptNotdienstSwapRequest(request.id);
|
||||
assert.match(accept.error ?? "", /Alarmierung/);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstSwapRequest.findUniqueOrThrow({
|
||||
where: { id: request.id },
|
||||
})
|
||||
).status,
|
||||
"PENDING",
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAssignment.findUniqueOrThrow({
|
||||
where: { id: f.assignment.id },
|
||||
})
|
||||
).childId,
|
||||
f.child.id,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAssignment.findUniqueOrThrow({
|
||||
where: { id: otherAssignment.id },
|
||||
})
|
||||
).childId,
|
||||
otherChild.id,
|
||||
);
|
||||
} finally {
|
||||
testUser = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("Nur aktuelle Admins und Koordination derselben Kita dürfen Alarme verwalten", async () => {
|
||||
const f = await fixture(),
|
||||
foreign = await fixture();
|
||||
await assert.rejects(f.create({}, f.staff), /Kein Zugriff/);
|
||||
await assert.rejects(f.create({}, f.parent), /Kein Zugriff/);
|
||||
await assert.rejects(f.create({}, foreign.admin), /nicht gefunden/);
|
||||
await assert.rejects(
|
||||
f.create({}, { ...f.admin, kitaId: foreign.kita.id }),
|
||||
/keinen Zugriff/,
|
||||
);
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { role: "ELTERN" },
|
||||
});
|
||||
await assert.rejects(f.create(), /keinen Zugriff/);
|
||||
const result = await f.create({}, f.coord);
|
||||
await assert.rejects(
|
||||
f.command("cancel", result.alarmId!, { reason: "Teststorno" }, f.parent),
|
||||
/Kein Zugriff/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Eingeteilter Haushalt erhält zwei persönliche Links, gemeinsame Antwort und 30 Minuten Frist", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.equal(alarm.deadline.getTime() - now.getTime(), 30 * 60_000);
|
||||
assert.equal(alarm.wholeDay, true);
|
||||
const recipients = await f.recipients(alarm.id);
|
||||
assert.deepEqual(
|
||||
recipients.map((r) => r.userId).sort(),
|
||||
[f.p1.id, f.p2.id].sort(),
|
||||
);
|
||||
assert.notEqual(recipients[0].token, recipients[1].token);
|
||||
assert.equal(result.emailIds.length, 2);
|
||||
assert.equal((await getAlarmFeed(f.parent, now)).length, 1);
|
||||
assert.equal((await getAlarmFeed(f.otherParent, now)).length, 0);
|
||||
assert.equal(await getPublicAlarm(randomUUID()), null);
|
||||
await respondToAlarm(
|
||||
recipients[0].token,
|
||||
true,
|
||||
new Date(now.getTime() + 60_000),
|
||||
);
|
||||
assert.equal(
|
||||
(await getPublicAlarm(recipients[1].token))?.status,
|
||||
"CONFIRMED",
|
||||
);
|
||||
await assert.rejects(
|
||||
respondToAlarm(recipients[1].token, false, now),
|
||||
/bereits/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Entwürfe, deaktivierte Module, vergangene Einsätze und ungültige Uhrzeiten werden abgewiesen", async () => {
|
||||
const f = await fixture();
|
||||
await prisma.notdienstPlan.update({
|
||||
where: { id: f.plan.id },
|
||||
data: { status: "DRAFT" },
|
||||
});
|
||||
await assert.rejects(f.create(), /veröffentlichen/);
|
||||
await prisma.notdienstPlan.update({
|
||||
where: { id: f.plan.id },
|
||||
data: { status: "PUBLISHED" },
|
||||
});
|
||||
await prisma.kita.update({
|
||||
where: { id: f.kita.id },
|
||||
data: { notdienstModuleEnabled: false },
|
||||
});
|
||||
await assert.rejects(f.create(), /deaktiviert/);
|
||||
await prisma.kita.update({
|
||||
where: { id: f.kita.id },
|
||||
data: { notdienstModuleEnabled: true },
|
||||
});
|
||||
await assert.rejects(
|
||||
f.create({ wholeDay: false, startMinute: 900, endMinute: 800 }),
|
||||
/Ende nach Beginn/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.create({ wholeDay: false, startMinute: 360, endMinute: 420 }),
|
||||
/beendet/,
|
||||
);
|
||||
await assert.rejects(f.create({ educatorId: randomUUID() }), /Fachkraft/);
|
||||
await assert.rejects(f.create({ replyMinutes: 0 }));
|
||||
await prisma.notdienstAssignment.update({
|
||||
where: { id: f.assignment.id },
|
||||
data: { date: dateOnly("2090-01-08") },
|
||||
});
|
||||
await assert.rejects(f.create(), /Vergangene/);
|
||||
});
|
||||
|
||||
test("Uhrzeiten gelten in Berlin und eine kurze Einsatzdauer begrenzt die Antwortfrist", async () => {
|
||||
const f = await fixture();
|
||||
const result = await f.create({
|
||||
wholeDay: false,
|
||||
startMinute: 480,
|
||||
endMinute: 495,
|
||||
});
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.equal(
|
||||
alarm.expiresAt.toISOString(),
|
||||
berlinInstant(date, 495).toISOString(),
|
||||
);
|
||||
assert.equal(alarm.deadline.toISOString(), alarm.expiresAt.toISOString());
|
||||
assert.equal(alarm.startMinute, 480);
|
||||
await assert.rejects(
|
||||
respondToAlarm(
|
||||
(await f.recipients(alarm.id))[0].token,
|
||||
true,
|
||||
alarm.expiresAt,
|
||||
),
|
||||
/abgelaufen/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Zeitumstellung und Tagesende werden unabhängig von der Serverzeitzone berechnet", () => {
|
||||
assert.equal(
|
||||
berlinInstant("2026-10-09", 480).toISOString(),
|
||||
"2026-10-09T06:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
berlinInstant("2026-12-09", 480).toISOString(),
|
||||
"2026-12-09T07:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
endOfAlarm("2026-03-29", true).toISOString(),
|
||||
"2026-03-29T22:00:00.000Z",
|
||||
);
|
||||
assert.equal(
|
||||
endOfAlarm("2026-10-25", true).toISOString(),
|
||||
"2026-10-25T23:00:00.000Z",
|
||||
);
|
||||
assert.throws(() => berlinInstant("2026-03-29", 150), /Zeitumstellung/);
|
||||
});
|
||||
|
||||
test("Parallele Auslösung erstellt genau einen aktiven Vorgang", async () => {
|
||||
const f = await fixture();
|
||||
const results = await Promise.allSettled([f.create(), f.create()]);
|
||||
assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarm.count({
|
||||
where: { activeAssignmentId: f.assignment.id },
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmRecipient.count({
|
||||
where: { alarm: { kitaId: f.kita.id } },
|
||||
}),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("Gegensätzliche gleichzeitige Haushaltsantworten können nicht beide gespeichert werden", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
const recipients = await f.recipients(result.alarmId!);
|
||||
const answers = await Promise.allSettled([
|
||||
respondToAlarm(recipients[0].token, true, now),
|
||||
respondToAlarm(recipients[1].token, false, now),
|
||||
]);
|
||||
assert.equal(answers.filter((a) => a.status === "fulfilled").length, 1);
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.ok(["CONFIRMED", "DECLINED"].includes(alarm.status));
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmHistory.count({
|
||||
where: {
|
||||
alarmId: alarm.id,
|
||||
action: { in: ["Zusage des Haushalts", "Absage des Haushalts"] },
|
||||
},
|
||||
}),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmEmail.count({
|
||||
where: { alarmId: alarm.id, kind: "MANAGER" },
|
||||
}),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fristprüfung eskaliert ohne Elternantwort genau einmal und erzeugt nur Koordinationshinweise", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
assert.equal(
|
||||
(
|
||||
await processAlarmDeadlines(
|
||||
new Date(now.getTime() + 29 * 60_000),
|
||||
f.kita.id,
|
||||
)
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
const deadline = new Date(now.getTime() + 30 * 60_000);
|
||||
const results = await Promise.all([
|
||||
processAlarmDeadlines(deadline, f.kita.id),
|
||||
processAlarmDeadlines(deadline, f.kita.id),
|
||||
]);
|
||||
assert.equal(results.flat().length, 2);
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||
where: { alarmId: result.alarmId, eventKey: "deadline" },
|
||||
});
|
||||
assert.deepEqual(
|
||||
jobs.map((j) => j.userId).sort(),
|
||||
[f.admin.id, f.coord.id].sort(),
|
||||
);
|
||||
assert.equal((await processAlarmDeadlines(deadline, f.kita.id)).length, 0);
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.ok(alarm.escalatedAt);
|
||||
assert.match(alarmLabel(alarm, deadline), /Frist abgelaufen/);
|
||||
});
|
||||
|
||||
test("Rechtzeitige Zusagen verhindern Fristmeldungen; Absagen informieren sofort", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
const token = (await f.recipients(result.alarmId!))[0].token;
|
||||
const response = await respondToAlarm(token, false, now);
|
||||
assert.equal(response.emailIds.length, 2);
|
||||
assert.equal(
|
||||
(
|
||||
await processAlarmDeadlines(
|
||||
new Date(now.getTime() + 60 * 60_000),
|
||||
f.kita.id,
|
||||
)
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
})
|
||||
).status,
|
||||
"DECLINED",
|
||||
);
|
||||
const other = await fixture(),
|
||||
accepted = await other.create();
|
||||
await respondToAlarm(
|
||||
(await other.recipients(accepted.alarmId!))[0].token,
|
||||
true,
|
||||
now,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await processAlarmDeadlines(
|
||||
new Date(now.getTime() + 60 * 60_000),
|
||||
other.kita.id,
|
||||
)
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("Verspätete Zusagen benötigen Freigabe; telefonische Korrekturen bleiben intern begründet", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create({
|
||||
notes: "PRIVATE_KRANKHEITSNOTIZ",
|
||||
educatorId: f.educator.id,
|
||||
});
|
||||
const late = new Date(now.getTime() + 31 * 60_000),
|
||||
token = (await f.recipients(result.alarmId!))[0].token;
|
||||
await respondToAlarm(token, true, late);
|
||||
assert.equal((await getPublicAlarm(token))?.status, "LATE_CONFIRMATION");
|
||||
await f.command(
|
||||
"approveLate",
|
||||
result.alarmId!,
|
||||
{ reason: "PRIVATE_FREIGABE" },
|
||||
f.coord,
|
||||
late,
|
||||
);
|
||||
assert.equal((await getPublicAlarm(token))?.status, "CONFIRMED");
|
||||
await f.command(
|
||||
"recordResponse",
|
||||
result.alarmId!,
|
||||
{ approve: false, reason: "PRIVATE_TELEFONNOTIZ" },
|
||||
f.admin,
|
||||
late,
|
||||
);
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
assert.equal(alarm.status, "DECLINED");
|
||||
assert.equal(alarm.responseSource, "PHONE");
|
||||
const jobs = await prisma.notdienstAlarmEmail.findMany({
|
||||
where: { alarmId: alarm.id },
|
||||
});
|
||||
assert.ok(
|
||||
jobs.every(
|
||||
(job) =>
|
||||
!job.message.includes("PRIVATE_") &&
|
||||
!job.message.includes("Interne Fachkraft"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("Stornierung invalidiert alte Links, informiert den Haushalt und erlaubt neue Alarmierung", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
await assert.rejects(
|
||||
assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id),
|
||||
/aktiver Alarm/,
|
||||
);
|
||||
const token = (await f.recipients(result.alarmId!))[0].token;
|
||||
const cancelled = await f.command("cancel", result.alarmId!, {
|
||||
reason: "PRIVATE_STORNIERUNG",
|
||||
});
|
||||
assert.equal(cancelled.emailIds.length, 2);
|
||||
await assert.rejects(respondToAlarm(token, true, now), /storniert/);
|
||||
await assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id);
|
||||
const renewed = await f.create();
|
||||
assert.notEqual(renewed.alarmId, result.alarmId);
|
||||
assert.equal((await getPublicAlarm(token))?.status, "CANCELLED");
|
||||
let requests = 0;
|
||||
await deliverAlarmEmails(
|
||||
result.emailIds,
|
||||
async () => {
|
||||
requests++;
|
||||
return { success: true };
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test("Versandfehler stoppen keine anderen Empfänger; erneuter Versand lässt Frist unverändert", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
let calls = 0;
|
||||
const messages: string[] = [];
|
||||
const delivery = await deliverAlarmEmails(
|
||||
result.emailIds,
|
||||
async (input) => {
|
||||
const number = ++calls;
|
||||
messages.push(await render(input.react));
|
||||
assert.ok(input.idempotencyKey?.startsWith("notdienst-alarm/"));
|
||||
return number === 1
|
||||
? { success: false, error: "Testausfall" }
|
||||
: { success: true };
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(delivery.failed, 1);
|
||||
assert.equal(delivery.sent, 1);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(html) => html.includes("2090-01-09") && html.includes("Ganzer Kita-Tag"),
|
||||
),
|
||||
);
|
||||
const original = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
const retry = await f.command("retry", original.id);
|
||||
assert.equal(retry.emailIds.length, 1);
|
||||
assert.equal(
|
||||
(
|
||||
await deliverAlarmEmails(
|
||||
retry.emailIds,
|
||||
async () => ({ success: true }),
|
||||
now,
|
||||
)
|
||||
).sent,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: original.id },
|
||||
})
|
||||
).deadline.getTime(),
|
||||
original.deadline.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
test("Paralleler Versand verwendet Aufträge einmal; geänderte Haushalte erhalten keine Anfrage", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create();
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return { success: true as const };
|
||||
};
|
||||
await Promise.all([
|
||||
deliverAlarmEmails(result.emailIds, send, now),
|
||||
deliverAlarmEmails(result.emailIds, send, now),
|
||||
]);
|
||||
assert.equal(calls, 2);
|
||||
const next = await fixture(),
|
||||
queued = await next.create();
|
||||
await prisma.user.update({
|
||||
where: { id: next.p1.id },
|
||||
data: { familyId: next.otherFamily.id },
|
||||
});
|
||||
const token = (await next.recipients(queued.alarmId!)).find(
|
||||
(r) => r.userId === next.p1.id,
|
||||
)!.token;
|
||||
assert.equal(await getPublicAlarm(token), null);
|
||||
const sent = await deliverAlarmEmails(queued.emailIds, send, now);
|
||||
assert.equal(sent.sent, 1);
|
||||
});
|
||||
|
||||
test("Abgelaufene Tokens können nicht antworten und abgelaufene Anfragen werden nicht versendet", async () => {
|
||||
const f = await fixture(),
|
||||
result = await f.create({
|
||||
wholeDay: false,
|
||||
startMinute: 480,
|
||||
endMinute: 540,
|
||||
});
|
||||
const alarm = await prisma.notdienstAlarm.findUniqueOrThrow({
|
||||
where: { id: result.alarmId },
|
||||
});
|
||||
await assert.rejects(
|
||||
respondToAlarm(
|
||||
(await f.recipients(alarm.id))[0].token,
|
||||
true,
|
||||
alarm.expiresAt,
|
||||
),
|
||||
/abgelaufen/,
|
||||
);
|
||||
let calls = 0;
|
||||
await deliverAlarmEmails(
|
||||
result.emailIds,
|
||||
async () => {
|
||||
calls++;
|
||||
return { success: true };
|
||||
},
|
||||
alarm.expiresAt,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
await processAlarmDeadlines(alarm.expiresAt, f.kita.id);
|
||||
assert.equal(
|
||||
(await prisma.notdienstAlarm.findUniqueOrThrow({ where: { id: alarm.id } }))
|
||||
.activeAssignmentId,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("Bisherige Einzelalarme werden vorsichtig übernommen und Originalstatus bleibt erhalten", async () => {
|
||||
const f = await fixture();
|
||||
for (const [user, status] of [
|
||||
[f.p1, "CONFIRMED"],
|
||||
[f.p2, "PENDING"],
|
||||
] as const)
|
||||
await prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
assignmentId: f.assignment.id,
|
||||
parentUserId: user.id,
|
||||
confirmationToken: randomUUID(),
|
||||
status,
|
||||
triggeredAt: now,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
assertAssignmentNotAlarmed(prisma, f.assignment.id, f.kita.id),
|
||||
/aktiver Alarm/,
|
||||
);
|
||||
assert.equal(await importLegacyAlarms(f.kita.id), 1);
|
||||
assert.equal(await importLegacyAlarms(f.kita.id), 0);
|
||||
const alarm = await prisma.notdienstAlarm.findFirstOrThrow({
|
||||
where: { kitaId: f.kita.id },
|
||||
});
|
||||
assert.equal(alarm.status, "REVIEW_REQUIRED");
|
||||
assert.equal(
|
||||
await prisma.notdienstAlarmEmail.count({ where: { alarmId: alarm.id } }),
|
||||
0,
|
||||
);
|
||||
const legacy = await prisma.notdienstAlert.findMany({
|
||||
where: { kitaId: f.kita.id },
|
||||
});
|
||||
assert.deepEqual(legacy.map((l) => l.status).sort(), [
|
||||
"CONFIRMED",
|
||||
"PENDING",
|
||||
]);
|
||||
assert.equal(
|
||||
(await getPublicAlarm(legacy[0].confirmationToken))?.status,
|
||||
"REVIEW_REQUIRED",
|
||||
);
|
||||
});
|
||||
|
||||
test("Gültige alte Links werden gemeinsam übernommen; unzuständige Konten können sie nicht benutzen", async () => {
|
||||
const f = await fixture();
|
||||
const tokens = [randomUUID(), randomUUID()];
|
||||
for (const [index, user] of [f.p1, f.p2].entries())
|
||||
await prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
assignmentId: f.assignment.id,
|
||||
parentUserId: user.id,
|
||||
confirmationToken: tokens[index],
|
||||
triggeredAt: now,
|
||||
},
|
||||
});
|
||||
assert.equal((await getPublicAlarm(tokens[0]))?.status, "PENDING");
|
||||
await respondToAlarm(tokens[0], true, now);
|
||||
assert.equal((await getPublicAlarm(tokens[1]))?.status, "CONFIRMED");
|
||||
await prisma.user.update({
|
||||
where: { id: f.p2.id },
|
||||
data: { role: "ERZIEHER" },
|
||||
});
|
||||
assert.equal(await getPublicAlarm(tokens[1]), null);
|
||||
await assert.rejects(respondToAlarm(tokens[1], false, now), /Ungültiger/);
|
||||
});
|
||||
|
||||
test("Jobzugriff benötigt ein eigenes langes Secret und validiert den Bearer-Header", () => {
|
||||
const secret = "test-only-notdienst-alarm-secret-0123456789";
|
||||
assert.equal(authorizeAlarmJob(null, ""), 503);
|
||||
assert.equal(authorizeAlarmJob(null, secret), 401);
|
||||
assert.equal(authorizeAlarmJob(`Bearer ${secret}x`, secret), 401);
|
||||
assert.equal(authorizeAlarmJob(`Bearer ${secret}`, secret), 200);
|
||||
});
|
||||
Reference in New Issue
Block a user