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
+29 -6
View File
@@ -23,6 +23,7 @@ function loader(overrides = {}) {
}).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() {} };
@@ -38,6 +39,7 @@ function loader(overrides = {}) {
}
return load;
}
process.env.AUTH_URL = 'http://127.0.0.1:3008';
let prisma, kitaA, kitaB, familyA, familyB, parentA, parentB, adminA, founder, superadmin, childA, childB, passHash;
const prefix = `security-${randomUUID()}`;
const emails = [];
@@ -255,14 +257,14 @@ test('participant secrets are removed from props delivered to unauthorized brows
assert.equal(JSON.stringify(await page(parentB)).includes(secret), false);
});
test('alarm links expire and concurrent confirmations consume the token only once', async () => {
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
const load = loader({ '@/lib/mail': emailMock }); const { newToken, hashToken } = load('src/lib/tokens.ts');
const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 1, status: 'PUBLISHED' } });
const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 86400000) } });
const token = newToken();
const alert = await prisma.notdienstAlert.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, parentUserId: parentA.id, confirmationToken: hashToken(token), triggeredAt: new Date(Date.now() - 48 * 3600000) } });
const alert = await prisma.notdienstAlarm.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, activeAssignmentId: assignment.id, familyId: familyA.id, familyName: 'A', childName: 'A Test', date: assignment.date, expiresAt: new Date(Date.now() - 1000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: parentA.id, token: hashToken(token), sessionVersion: parentA.sessionVersion } } } });
const confirm = load('src/app/alert/[token]/actions.ts').confirmAlertAction;
assert.ok((await confirm(token)).error);
await prisma.notdienstAlert.update({ where: { id: alert.id }, data: { triggeredAt: new Date() } });
await prisma.notdienstAlarm.update({ where: { id: alert.id }, data: { expiresAt: new Date(Date.now() + 86400000) } });
const results = await Promise.all([confirm(token), confirm(token)]);
assert.equal(results.filter(result => result.success).length, 1);
assert.ok((await confirm(token)).error);
@@ -353,7 +355,7 @@ test('upload quota holds under concurrent writes and accepted MIME comes from fi
test('all supplemental database tenant constraints are present', async () => {
const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`;
assert.equal(row.count,31);
assert.equal(row.count,41);
});
test('role guard authenticates once and keeps role validation on that session', async () => {
let calls = 0;
@@ -370,9 +372,30 @@ test('parallel alarm triggers send one set of messages and skip unverified recip
const before = emails.length;
const results = await Promise.all([trigger(assignment.id),trigger(assignment.id)]);
assert.equal(results.filter(result => result.success).length,1);
const alerts = await prisma.notdienstAlert.findMany({ where: { assignmentId: assignment.id } });
const alerts = await prisma.notdienstAlarmRecipient.findMany({ where: { alarm: { assignmentId: assignment.id } } });
assert.ok(alerts.length > 0);
assert.equal(alerts.some(alert => alert.parentUserId === unverified.id),false);
assert.equal(alerts.some(alert => alert.userId === unverified.id),false);
assert.equal(emails.slice(before).some(email => email.to === unverified.email),false);
assert.equal(emails.length - before,alerts.length);
});
test('new alarm links reject stored hashes and stay invalid after account unlock', async () => {
const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts');
const { getPublicAlarm } = load('src/lib/alarms/service.ts');
const user = await prisma.user.create({ data: { email: `alarm-version.${prefix}@example.invalid`, firstName: 'Parent', lastName: 'Test', role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
const token = newToken();
await prisma.notdienstAlarm.create({ data: { kitaId: kitaA.id, familyId: familyA.id, familyName: 'A', childName: 'A', date: new Date(), expiresAt: new Date(Date.now() + 86400000), deadline: new Date(Date.now() + 3600000), recipients: { create: { userId: user.id, token: hashToken(token), sessionVersion: user.sessionVersion } } } });
assert.ok(await getPublicAlarm(token));
assert.equal(await getPublicAlarm(hashToken(token)), null);
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
assert.equal(await getPublicAlarm(token), null);
await prisma.user.update({ where: { id: user.id }, data: { disabledAt: null } });
assert.equal(await getPublicAlarm(token), null);
});
test('new staff and household event relationships reject foreign tenants in the database', async () => {
const educator = await prisma.educator.create({ data: { kitaId: kitaA.id, firstName: 'Staff', lastName: 'Test' } });
await assert.rejects(prisma.staffProfile.create({ data: { kitaId: kitaB.id, educatorId: educator.id, employmentStart: new Date(), trackingStart: new Date() } }));
const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Tenant event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date(Date.now() + 3600000), createdById: adminA.id } });
await assert.rejects(prisma.familyEventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, familyId: familyB.id, status: 'ATTENDING' } }));
});