feat: show planning availability and manage Kita access roles

This commit is contained in:
t.indorf
2026-10-12 16:28:56 +02:00
parent 99d3a63eed
commit 061024d901
29 changed files with 2347 additions and 404 deletions
+599
View File
@@ -0,0 +1,599 @@
/* 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)));
});