/* eslint-disable @typescript-eslint/no-require-imports -- Test real server code with isolated request and transport boundaries. */ const { test, before, after, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const ts = require("typescript"); const { PrismaClient } = require("@prisma/client"); const { randomUUID } = require("node:crypto"); let prisma, current, f, change, changeAction, remove, support, updateFamily, alarms, alarmMail, staff, staffMail, assign; const emails = []; const tenants = [], looseUsers = []; function loader(overrides = {}) { const cache = new Map(); function load(file) { file = path.resolve(file); if (cache.has(file)) return cache.get(file).exports; const mod = { exports: {} }; cache.set(file, mod); const code = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true, }, fileName: file, }).outputText; const req = (spec) => { if (Object.hasOwn(overrides, spec)) return overrides[spec]; if (spec === "server-only") return {}; if (spec === "@/lib/prisma") return { prisma }; if (spec === "next/headers") return { headers: async () => new Headers() }; if (spec === "next/cache") return { revalidatePath() {} }; if (spec === "next/navigation") return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error("NOT_FOUND"); }, }; if (spec.startsWith("@/") || spec.startsWith(".")) { const target = spec.startsWith("@/") ? path.resolve("src", spec.slice(2)) : path.resolve(path.dirname(file), spec); for (const suffix of [".ts", ".tsx", "/index.ts"]) if (fs.existsSync(target + suffix)) return load(target + suffix); } return require(spec); }; vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file, })(req, mod, mod.exports); return mod.exports; } return load; } before(async () => { const raw = process.env.TEST_DATABASE_URL; assert.ok( raw, "TEST_DATABASE_URL muss ausdrücklich auf die separate lokale kita_roles_test zeigen.", ); const url = new URL(raw); assert.ok(["127.0.0.1", "localhost"].includes(url.hostname)); assert.equal(url.pathname, "/kita_roles_test"); prisma = new PrismaClient({ datasourceUrl: raw }); const load = loader({ "@/lib/auth-utils": { requireKitaSession: async () => ({ user: current }), requireRole: async (roles) => { assert.ok(roles.includes(current.role)); return { user: current }; }, }, "@/lib/mail": { getAppEmailConfigError: () => null, sendAppEmail: async (input) => { emails.push(input); return { success: true }; }, }, }); change = load("src/lib/kita-role-management.ts").changeKitaRole; changeAction = load( "src/app/dashboard/zugriffsrechte/actions.ts", ).changeKitaRoleAction; remove = load("src/app/dashboard/profil/actions.ts").deleteMyAccount; support = load("src/app/admin/actions.ts"); updateFamily = load( "src/app/dashboard/families/actions.ts", ).updateFamilyAction; alarms = load("src/lib/alarms/service.ts"); alarmMail = load("src/lib/alarms/email.ts"); staff = load("src/lib/staff/service.ts"); staffMail = load("src/lib/staff/email.ts"); assign = load( "src/app/dashboard/notdienst/plan/actions.ts", ).updateAssignmentAction; process.env.AUTH_URL = "http://127.0.0.1:3008"; }); async function user(role, extra = {}, kitaId = f.kita.id) { return prisma.user.create({ data: { kitaId, email: `${randomUUID()}@example.invalid`, firstName: "Test", lastName: role, passwordHash: "test-only", emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), role, ...extra, }, }); } const actor = (u) => ({ id: u.id, kitaId: u.kitaId, sessionVersion: u.sessionVersion, }); const changeTo = (u, role, by = f.admin) => change(actor(by), { userId: u.id, role, expectedRole: u.role }); beforeEach(async () => { const kita = await prisma.kita.create({ data: { name: "Rollen-Test", slug: `roles-${randomUUID()}` }, }); tenants.push(kita.id); f = { kita }; f.admin = await user("ADMIN"); f.family = await prisma.family.create({ data: { kitaId: kita.id, name: "Beispiel" }, }); f.parent = await user("ELTERN", { familyId: f.family.id }); f.child = await prisma.child.create({ data: { kitaId: kita.id, familyId: f.family.id, firstName: "Kind", lastName: "Test", }, }); current = f.admin; emails.length = 0; }); after(async () => { if (!prisma) return; await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); await prisma.user.deleteMany({ where: { id: { in: looseUsers } } }); await prisma.auditLog.deleteMany({ where: { targetKitaId: { in: tenants } }, }); await prisma.$disconnect(); }); test("Eltern werden Koordination und Kita-Admin; Haushalt und Kind bleiben erhalten", async () => { assert.equal( ( await changeAction({ userId: f.parent.id, expectedRole: "ELTERN", role: "KOORDINATOR", }) ).success, true, ); const coord = await prisma.user.findUniqueOrThrow({ where: { id: f.parent.id }, }); await changeTo(coord, "ADMIN"); const updated = await prisma.user.findUniqueOrThrow({ where: { id: coord.id }, }); assert.equal(updated.familyId, f.family.id); assert.equal(updated.sessionVersion, coord.sessionVersion); assert.equal( await prisma.child.count({ where: { familyId: f.family.id } }), 1, ); }); test("Rollenänderung protokolliert Akteur, Kita und vorherige/neue Rolle genau einmal", async () => { await changeTo(f.parent, "ADMIN"); const jobs = await prisma.auditLog.findMany({ where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" }, }); assert.equal(jobs.length, 1); assert.equal(jobs[0].actorUserId, f.admin.id); assert.deepEqual(jobs[0].metadata, { previousRole: "ELTERN", newRole: "ADMIN", }); assert.equal( (await changeTo({ ...f.parent, role: "ADMIN" }, "ADMIN")).changed, false, ); assert.equal( await prisma.auditLog.count({ where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" }, }), 1, ); }); test("Konto ohne Haushalt darf Koordination werden, aber nicht Eltern oder Fachkraft", async () => { const person = await user("KOORDINATOR"); await assert.rejects(changeTo(person, "ELTERN"), /Haushalt/); await assert.rejects(changeTo(person, "ERZIEHER"), /Personalprofil/); assert.equal((await changeTo(person, "ADMIN")).changed, true); }); test("Superadmin-Rolle kann weder vergeben noch verändert werden", async () => { await assert.rejects(changeTo(f.parent, "SUPERADMIN"), /Ungültige/); const root = await user("SUPERADMIN"); await assert.rejects( change(actor(f.admin), { userId: root.id, expectedRole: "ADMIN", role: "KOORDINATOR", }), /nicht geändert/, ); }); test("Fremde Kita ist bei Zielkonto und Akteur ausgeschlossen", async () => { const foreign = await prisma.kita.create({ data: { name: "Andere", slug: `roles-${randomUUID()}` }, }); tenants.push(foreign.id); const outsider = await user("ADMIN", {}, foreign.id); await assert.rejects(changeTo(outsider, "KOORDINATOR"), /nicht geändert/); await assert.rejects( change( { ...actor(f.admin), kitaId: foreign.id }, { userId: outsider.id, expectedRole: "ADMIN", role: "KOORDINATOR" }, ), /nicht mehr ändern/, ); }); test("Eltern, gesperrte Admins und veraltete Sessions dürfen keine Rollen ändern", async () => { await assert.rejects( change(actor(f.parent), { userId: f.admin.id, expectedRole: "ADMIN", role: "KOORDINATOR", }), /nicht mehr ändern/, ); await assert.rejects( change( { ...actor(f.admin), sessionVersion: 9 }, { userId: f.parent.id, expectedRole: "ELTERN", role: "ADMIN" }, ), /nicht mehr ändern/, ); await prisma.user.update({ where: { id: f.admin.id }, data: { disabledAt: new Date() }, }); await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/); }); test("Veralteter Dialog überschreibt keine zwischenzeitliche Rollenänderung", async () => { await changeTo(f.parent, "KOORDINATOR"); await assert.rejects(changeTo(f.parent, "ADMIN"), /inzwischen geändert/); assert.equal( (await prisma.user.findUnique({ where: { id: f.parent.id } })).role, "KOORDINATOR", ); }); test("Letzter Admin kann seine Rolle nicht abgeben", async () => { await assert.rejects( changeTo(f.admin, "KOORDINATOR"), /Mindestens ein aktiver/, ); }); for (const [label, extra] of [ ["offene Einladung", { emailVerifiedAt: null, passwordHash: "" }], ["deaktiviert", { disabledAt: new Date() }], ["gesperrt", { lockedUntil: new Date("2099-01-01") }], ["ohne Zustimmung", { privacyPolicyAcceptedAt: null }], ]) { test(`Ein weiterer Admin ${label} reicht nicht als Ersatz`, async () => { await user("ADMIN", extra); await assert.rejects( changeTo(f.admin, "KOORDINATOR"), /Mindestens ein aktiver/, ); }); } test("Mit zweitem aktivierten Admin ist eigene Herabstufung möglich und stale Admin-Rechte sind unwirksam", async () => { await user("ADMIN"); assert.equal((await changeTo(f.admin, "KOORDINATOR")).ownRoleChanged, true); await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/); }); test("Parallele Herabstufung zweier Admins lässt genau einen Admin bestehen", async () => { const second = await user("ADMIN"); const results = await Promise.allSettled([ changeTo(f.admin, "KOORDINATOR", f.admin), changeTo(second, "KOORDINATOR", second), ]); assert.equal(results.filter((r) => r.status === "fulfilled").length, 1); assert.equal( await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }), 1, ); }); test("Eigene Kontolöschung ist für letzten Admin gesperrt", async () => { assert.match((await remove()).error, /Mindestens ein aktiver/); assert.ok(await prisma.user.findUnique({ where: { id: f.admin.id } })); }); test("Eigene Kontolöschung mit zweitem aktivem Admin funktioniert weiterhin", async () => { await user("ADMIN"); assert.equal((await remove()).success, true); assert.equal( await prisma.user.findUnique({ where: { id: f.admin.id } }), null, ); }); test("Superadmin kann den letzten Kita-Admin weder löschen noch sperren", async () => { current = await user("SUPERADMIN", {}, null); looseUsers.push(current.id); current.authenticatedAt = Date.now(); assert.match( ( await support.deleteUser({ userId: f.admin.id, confirmationEmail: f.admin.email, }) ).error, /Mindestens ein aktiver/, ); assert.match( (await support.lockUser(f.admin.id)).error, /Mindestens ein aktiver/, ); }); test("Support darf zusätzlichen Admin sperren und löschen", async () => { const second = await user("ADMIN"); current = await user("SUPERADMIN", {}, null); looseUsers.push(current.id); current.authenticatedAt = Date.now(); assert.equal((await support.lockUser(second.id)).ok, true); assert.equal( ( await support.deleteUser({ userId: second.id, confirmationEmail: second.email, }) ).ok, true, ); }); test("Gleichzeitige Admin-Löschung und Herabstufung lässt einen Admin bestehen", async () => { const second = await user("ADMIN"); const results = await Promise.allSettled([ remove(), changeTo(second, "KOORDINATOR"), ]); const successful = results.filter((r) => r.status === "fulfilled" && r.value.success).length + results.filter((r) => r.status === "fulfilled" && r.value.changed).length; assert.equal(successful, 1); assert.equal( await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }), 1, ); }); test("E-Mail-Änderung darf letzten aktivierten Admin nicht deaktivieren", async () => { await prisma.user.update({ where: { id: f.admin.id }, data: { familyId: f.family.id }, }); const payload = { familyId: f.family.id, familyName: f.family.name, parents: [ f.parent, { ...f.admin, email: `${randomUUID()}@example.invalid` }, ], children: [f.child], }; assert.match((await updateFamily(payload)).error, /Mindestens ein aktiver/); assert.equal( (await prisma.user.findUnique({ where: { id: f.admin.id } })).email, f.admin.email, ); }); test("Fachkraft wird Admin; Personalprofil bleibt bearbeitbar und eigene Mailpräferenz erhalten", async () => { const person = await user("ERZIEHER"); const educator = await prisma.educator.create({ data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" }, }); const profile = await prisma.staffProfile.create({ data: { kitaId: f.kita.id, educatorId: educator.id, userId: person.id, employmentStart: new Date("2030-01-01"), trackingStart: new Date("2030-01-01"), }, }); await assert.rejects(changeTo(person, "KOORDINATOR"), /Personalprofil/); await changeTo(person, "ADMIN"); await staff.executeStaffCommand( { id: person.id, kitaId: f.kita.id, role: "ADMIN" }, { command: "emailPreference", data: { enabled: false } }, ); await staff.executeStaffCommand( { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, { command: "educatorUpdate", data: { educatorId: educator.id, firstName: "Team", lastName: "Geändert", active: true, email: person.email, }, }, ); assert.equal( (await prisma.staffProfile.findUnique({ where: { id: profile.id } })) .userId, person.id, ); assert.equal( (await prisma.user.findUnique({ where: { id: person.id } })) .staffDecisionEmails, false, ); await changeTo({ ...person, role: "ADMIN" }, "ERZIEHER"); }); test("Offene Fachkraft-Einladung lässt sich auch nach Admin-Ernennung erneut senden", async () => { const person = await user("ERZIEHER", { emailVerifiedAt: null, passwordHash: "", }); const educator = await prisma.educator.create({ data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" }, }); await prisma.staffProfile.create({ data: { kitaId: f.kita.id, educatorId: educator.id, userId: person.id, employmentStart: new Date("2030-01-01"), trackingStart: new Date("2030-01-01"), }, }); await changeTo(person, "ADMIN"); const queued = await staff.executeStaffCommand( { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, { command: "educatorInvite", data: { educatorId: educator.id } }, ); assert.equal( ( await staffMail.deliverStaffEmails(queued.emailIds, async () => ({ success: true, })) ).sent, 1, ); }); for (const role of ["ADMIN", "KOORDINATOR"]) test(`Haushaltsalarme erreichen und akzeptieren Eltern mit Rolle ${role}`, async () => { await changeTo(f.parent, role); const plan = await prisma.notdienstPlan.create({ data: { kitaId: f.kita.id, year: 2030, month: 11, status: "PUBLISHED", assignments: { create: { kitaId: f.kita.id, childId: f.child.id, date: new Date("2030-11-04"), }, }, }, include: { assignments: true }, }); const job = await alarms.executeAlarmCommand( { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, "create", { assignmentId: plan.assignments[0].id, wholeDay: true }, ); const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({ where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" }, }); const token = request.path.split("/").pop(); assert.ok(await alarms.getPublicAlarm(token)); assert.equal( ( await alarmMail.deliverAlarmEmails([request.id], async () => ({ success: true, })) ).sent, 1, ); await alarms.respondToAlarm(token, true); assert.equal( (await prisma.notdienstAlarm.findUnique({ where: { id: job.alarmId } })) .status, "CONFIRMED", ); }); test("Bereits ausgestellter Haushalts-Alarmlink bleibt nach Ernennung gültig", async () => { const plan = await prisma.notdienstPlan.create({ data: { kitaId: f.kita.id, year: 2030, month: 11, status: "PUBLISHED", assignments: { create: { kitaId: f.kita.id, childId: f.child.id, date: new Date("2030-11-04"), }, }, }, include: { assignments: true }, }); const job = await alarms.executeAlarmCommand( { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, "create", { assignmentId: plan.assignments[0].id, wholeDay: true }, ); const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({ where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" }, }); await changeTo(f.parent, "ADMIN"); assert.ok(await alarms.getPublicAlarm(request.path.split("/").pop())); }); test("Letzter Admin im Ein-Personen-Haushalt bleibt auch vor kaskadierender Familienlöschung geschützt", async () => { await prisma.user.update({ where: { id: f.parent.id }, data: { role: "ADMIN" }, }); await prisma.user.update({ where: { id: f.admin.id }, data: { role: "KOORDINATOR" }, }); current = { ...f.parent, role: "ADMIN" }; assert.match((await remove()).error, /Mindestens ein aktiver/); assert.ok(await prisma.family.findUnique({ where: { id: f.family.id } })); assert.ok(await prisma.child.findUnique({ where: { id: f.child.id } })); }); test("Veröffentlichte Umteilung informiert auch den Admin im betroffenen Elternhaushalt", async () => { await changeTo(f.parent, "ADMIN"); const family = await prisma.family.create({ data: { kitaId: f.kita.id, name: "Ersatz" }, }); const parent = await user("ELTERN", { familyId: family.id }); const child = await prisma.child.create({ data: { kitaId: f.kita.id, familyId: family.id, firstName: "Ersatz", lastName: "Test", }, }); const plan = await prisma.notdienstPlan.create({ data: { kitaId: f.kita.id, year: 2030, month: 11, status: "PUBLISHED", assignments: { create: { kitaId: f.kita.id, childId: f.child.id, date: new Date("2030-11-04"), }, }, }, }); const result = await assign(plan.id, "2030-11-04", child.id); assert.equal(result.success, true); assert.ok(emails.some((email) => [email.to].flat().includes(f.parent.email))); assert.ok(emails.some((email) => [email.to].flat().includes(parent.email))); });