Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+820
View File
@@ -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);
});