/* eslint-disable @typescript-eslint/no-require-imports -- CommonJS is required by the isolated TypeScript module loader. */ const { test, before, after } = 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, UserRole } = require('@prisma/client'); const { hash, compare } = require('bcryptjs'); const { randomUUID } = require('node:crypto'); // Execute the actual application code. Only the Next request/UI boundary and // outgoing email transport are replaced. Database queries and bcrypt are real. 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; } 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 = []; const session = user => ({ user: { id: user.id, kitaId: user.kitaId, familyId: user.familyId, role: user.role, email: user.email, sessionVersion: user.sessionVersion, authenticatedAt: Date.now() } }); const fields = data => { const form = new FormData(); for (const [key, value] of Object.entries(data)) form.set(key, value); return form; }; const emailMock = { getAppEmailConfigError: () => null, sendAppEmail: async input => { emails.push(input); return { success: true }; } }; const noAuth = { auth: async () => null, signIn: async () => {} }; let authConfig; before(async () => { const url = process.env.SECURITY_TEST_DATABASE_URL; if (!url || !['127.0.0.1','localhost'].includes(new URL(url).hostname) || !new URL(url).pathname.endsWith('/kita_security_test')) { throw new Error('Set SECURITY_TEST_DATABASE_URL to the dedicated local kita_security_test database; never use application data.'); } prisma = new PrismaClient({ datasourceUrl: url }); passHash = await hash('Safe-test-password', 12); kitaA = await prisma.kita.create({ data: { name: prefix + '-A', slug: prefix + '-a' } }); kitaB = await prisma.kita.create({ data: { name: prefix + '-B', slug: prefix + '-b' } }); familyA = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'A' } }); familyB = await prisma.family.create({ data: { kitaId: kitaB.id, name: 'B' } }); const makeUser = (label, role, kitaId, familyId) => prisma.user.create({ data: { email: `${label}.${prefix}@example.invalid`, firstName: label, lastName: 'Test', role, kitaId, familyId, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } }); parentA = await makeUser('parent-a', UserRole.ELTERN, kitaA.id, familyA.id); parentB = await makeUser('parent-b', UserRole.ELTERN, kitaB.id, familyB.id); adminA = await makeUser('admin-a', UserRole.ADMIN, kitaA.id, null); founder = await makeUser('founder', UserRole.ADMIN, null, null); superadmin = await makeUser('superadmin', UserRole.SUPERADMIN, null, null); childA = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: familyA.id, firstName: 'A', lastName: 'Test' } }); childB = await prisma.child.create({ data: { kitaId: kitaB.id, familyId: familyB.id, firstName: 'B', lastName: 'Test' } }); loader({ 'next-auth': config => { authConfig = config; return {}; }, 'next-auth/providers/credentials': options => options })('src/auth.ts'); }); after(async () => { if (!prisma) return; await prisma.verificationToken.deleteMany({ where: { identifier: { in: [parentA?.id, parentB?.id].filter(Boolean) } } }); await prisma.pendingRegistration.deleteMany({ where: { email: { contains: prefix } } }); await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } }); await prisma.user.deleteMany({ where: { email: { contains: prefix } } }); await prisma.auditLog.deleteMany({ where: { actorEmail: { contains: prefix } } }); await prisma.$disconnect(); }); test('bcrypt limit counts UTF-8 bytes, preventing equivalent long passwords', async () => { const { passwordSchema } = loader()('src/lib/password.ts'); assert.equal(passwordSchema.safeParse('😀'.repeat(18)).success, true); assert.equal(passwordSchema.safeParse('😀'.repeat(18) + 'A').success, false); assert.equal(passwordSchema.safeParse('a'.repeat(73)).success, false); assert.equal(await compare('😀'.repeat(18) + 'B', await hash('😀'.repeat(18) + 'A', 4)), true); }); test('redirects reject external, backslash and control-character destinations', () => { const { safeCallbackUrl } = loader()('src/lib/safe-redirect.ts'); for (const bad of ['//evil.invalid','/\\evil.invalid','https://evil.invalid','/\nevil']) assert.equal(safeCallbackUrl(bad), '/dashboard'); assert.equal(safeCallbackUrl('/admin?view=1'), '/admin?view=1'); }); test('invalid calendar dates and planning months are rejected', () => { const { dateOnlySchema, validPlanMonth } = loader()('src/lib/date-schema.ts'); for (const bad of ['2026-02-31','2026-13-01','9999-99-99','garbage']) assert.equal(dateOnlySchema.safeParse(bad).success, false); assert.equal(dateOnlySchema.safeParse('2028-02-29').success, true); assert.equal(validPlanMonth(2026, 13), false); assert.equal(validPlanMonth(2026.5, 1), false); }); test('upload checks reject HTML disguised as JPG and path traversal', () => { const files = loader()('src/lib/attachments.ts'); assert.equal(files.detectAttachmentType(Buffer.from('')), null); assert.throws(() => files.attachmentPath('../../etc/passwd')); assert.equal(files.attachmentExtensionMatches('attack.jpg', 'application/pdf'), false); assert.match(files.attachmentDisposition('a\r\nX-Injected: yes.pdf'), /^attachment;/); assert.doesNotMatch(files.attachmentDisposition('a\r\nX-Injected: yes.pdf'), /[\r\n]/); }); test('rate limiting is atomic under concurrent requests and expires correctly', async () => { const { rateLimit } = loader()('src/lib/rate-limit.ts'); const results = await Promise.all(Array.from({ length: 20 }, () => rateLimit(prefix + ':parallel', 5, 60))); assert.equal(results.filter(result => result.allowed).length, 5); assert.equal((await rateLimit(prefix + ':parallel', 5, 60)).allowed, false); const { createHash } = require('node:crypto'); await prisma.rateLimitBucket.update({ where: { key: createHash('sha256').update(prefix + ':parallel').digest('hex') }, data: { resetAt: new Date(0) } }); assert.equal((await rateLimit(prefix + ':parallel', 5, 60)).allowed, true); }); test('JWT callback rejects deleted, malformed and old-format sessions', async () => { assert.equal(await authConfig.callbacks.jwt({ token: {} }), null); assert.equal(await authConfig.callbacks.jwt({ token: { id: parentA.id } }), null); assert.equal(await authConfig.callbacks.jwt({ token: { id: 'missing-' + prefix, sessionVersion: 0, authenticatedAt: Date.now() } }), null); }); test('administrative lock and unlock permanently revoke existing sessions', async () => { const admitted = await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' }); assert.equal(admitted.id, parentA.id); const token = await authConfig.callbacks.jwt({ token: {}, user: admitted }); const admin = loader({ '@/auth': { auth: async () => session(superadmin) } })('src/app/admin/actions.ts'); assert.equal((await admin.lockUser(parentA.id)).ok, true); assert.equal(await authConfig.callbacks.jwt({ token: { ...token } }), null); assert.equal(await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' }), null); assert.equal((await admin.unlockUser(parentA.id)).ok, true); assert.equal(await authConfig.callbacks.jwt({ token: { ...token } }), null); const relogin = await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' }); assert.equal(relogin.sessionVersion, token.sessionVersion + 1); parentA = await prisma.user.findUnique({ where: { id: parentA.id } }); }); test('parallel incorrect passwords cannot lose failed-login increments', async () => { await Promise.all(Array.from({ length: 8 }, () => authConfig.providers[0].authorize({ email: parentB.email, password: 'wrong' }))); const user = await prisma.user.findUnique({ where: { id: parentB.id } }); assert.equal(user.failedLoginAttempts, 8); await prisma.user.update({ where: { id: parentB.id }, data: { failedLoginAttempts: 0 } }); }); test('normal roles require consent and tenant; TEST_MODE never creates a fake identity', async () => { await prisma.user.update({ where: { id: adminA.id }, data: { privacyPolicyAcceptedAt: null } }); const util = loader({ '@/auth': { auth: async () => session(adminA) } })('src/lib/auth-utils.ts'); await assert.rejects(util.requireRole([UserRole.ADMIN]), /REDIRECT:\/onboarding\/consent/); assert.equal(await util.getKitaSession(), null); await prisma.user.update({ where: { id: adminA.id }, data: { privacyPolicyAcceptedAt: new Date() } }); const old = process.env.TEST_MODE; process.env.TEST_MODE = 'true'; try { await assert.rejects(loader({ '@/auth': noAuth })('src/lib/auth-utils.ts').requireRole([UserRole.SUPERADMIN]), /REDIRECT:\/login/); } finally { if (old === undefined) delete process.env.TEST_MODE; else process.env.TEST_MODE = old; } }); test('onboarding denies missing IDs, global admins and unverified founders', async () => { const payload = fields({ kitaName: 'Test', notdienstMinPerChildPerMonth: '1' }); for (const bad of [null, { user: { role: UserRole.ADMIN } }, session(superadmin)]) { const action = loader({ '@/auth': { auth: async () => bad } })('src/app/onboarding/actions.ts').completeOnboardingAction; await assert.rejects(action({ attempt: 0 }, payload), /REDIRECT:/); } await prisma.user.update({ where: { id: founder.id }, data: { emailVerifiedAt: null } }); await assert.rejects(loader({ '@/auth': { auth: async () => session(founder) } })('src/app/onboarding/actions.ts').completeOnboardingAction({ attempt: 0 }, payload), /REDIRECT:\/register/); await prisma.user.update({ where: { id: founder.id }, data: { emailVerifiedAt: new Date() } }); }); test('database blocks cross-tenant child, user, RSVP and announcement links', async () => { await assert.rejects(prisma.child.create({ data: { firstName: 'Wrong', lastName: 'Tenant', kitaId: kitaA.id, familyId: familyB.id } }), error => error.code === 'P2003'); await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { familyId: familyB.id } }), error => error.code === 'P2003'); await assert.rejects(prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentB.id, title: 'Wrong', content: 'Wrong' } }), error => error.code === 'P2003'); await assert.rejects(prisma.invitation.create({ data: { kitaId: kitaA.id, userId: parentB.id, email: 'cross-' + prefix + '@example.invalid', token: randomUUID(), expiresAt: new Date(Date.now() + 60000), invitedById: adminA.id } }), error => error.code === 'P2003'); const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date() } }); await assert.rejects(prisma.eventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, childId: childB.id, status: 'ATTENDING' } }), error => error.code === 'P2003'); }); test('download route rejects foreign attachments and requires consent', async () => { const { writeFile, mkdir } = require('node:fs/promises'); const files = loader()('src/lib/attachments.ts'); const id = randomUUID(); const announcement = await prisma.announcement.create({ data: { kitaId: kitaB.id, authorId: parentB.id, title: 'Private', content: 'Private', attachments: { create: { id, fileName: 'unsafe.html', fileType: 'text/html', fileUrl: '/api/' + id } } } }); await mkdir(files.attachmentDirectory(), { recursive: true }); await writeFile(files.attachmentPath(id), ''); try { const load = user => loader({ '@/auth': { auth: async () => session(user) } })('src/app/api/announcements/attachments/[id]/route.ts').GET(new Request('http://localhost'), { params: Promise.resolve({ id }) }); assert.equal((await load(parentA)).status, 404); const response = await load(parentB); assert.equal(response.status, 200); assert.equal(response.headers.get('content-type'), 'application/octet-stream'); assert.match(response.headers.get('content-disposition'), /^attachment;/); assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); assert.equal(response.headers.get('cache-control'), 'private, no-store'); await prisma.user.update({ where: { id: parentB.id }, data: { privacyPolicyAcceptedAt: null } }); assert.equal((await load(parentB)).status, 401); } finally { await files.removeAttachmentFiles([id]); await prisma.user.update({ where: { id: parentB.id }, data: { privacyPolicyAcceptedAt: new Date() } }); await prisma.announcement.delete({ where: { id: announcement.id } }); } }); test('registration has no user before mail verification and token can be consumed once', async () => { const email = `registration.${prefix}@example.invalid`; const load = loader({ '@/auth': noAuth, '@/lib/mail': emailMock }); const registration = load('src/app/register/actions.ts'); const result = await registration.registerAction({ attempt: 0 }, fields({ email, firstName: 'Founder', lastName: 'Test', acceptPrivacyPolicy: 'on' })); assert.ok(result.message); assert.equal(await prisma.user.findUnique({ where: { email } }), null); const message = emails.at(-1); const link = message.react.props.children[1].props.href; const token = link.split('/').at(-1); const draft = await prisma.pendingRegistration.findUnique({ where: { email } }); assert.notEqual(draft.tokenHash, token); const verify = load('src/app/register/verify/[token]/actions.ts').verifyRegistrationAction; const form = () => fields({ token, password: 'Owner-chosen-password', confirmPassword: 'Owner-chosen-password', acceptPrivacyPolicy: 'on' }); const results = await Promise.all([verify({}, form()), verify({}, form())]); assert.equal(results.filter(result => !result.error).length, 1); const user = await prisma.user.findUnique({ where: { email } }); assert.ok(user.emailVerifiedAt); assert.equal(user.kitaId, null); assert.equal(await compare('Owner-chosen-password', user.passwordHash), true); assert.ok((await verify({}, form())).error); }); test('invite activation uses a hashed expiring token and rejects concurrent reuse', async () => { const load = loader({ '@/auth': noAuth }); const { newToken, hashToken } = load('src/lib/tokens.ts'); const token = newToken(); const email = `invite.${prefix}@example.invalid`; const user = await prisma.user.create({ data: { email, firstName: 'Invited', lastName: 'Test', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } }); await prisma.verificationToken.create({ data: { token: hashToken(token), identifier: user.id, expires: new Date(Date.now() + 60_000) } }); const accept = load('src/app/invite/[token]/actions.ts').acceptInviteAction; const form = () => fields({ token, password: 'Invite-test-password', confirmPassword: 'Invite-test-password', acceptPrivacyPolicy: 'on' }); const results = await Promise.all([accept({ attempt: 0 }, form()), accept({ attempt: 0 }, form())]); assert.equal(results.filter(result => !result.errors).length, 1); assert.equal(await prisma.verificationToken.findUnique({ where: { token: hashToken(token) } }), null); }); test('household creation is atomic for parallel submissions', async () => { const action = loader({ '@/auth': { auth: async () => session(adminA) } })('src/app/dashboard/profil/actions.ts').createMyFamily; const before = await prisma.family.count({ where: { kitaId: kitaA.id } }); const results = await Promise.all([action({ familyName: 'Test household' }), action({ familyName: 'Test household' })]); assert.equal(results.filter(result => result.success).length, 1); assert.equal(await prisma.family.count({ where: { kitaId: kitaA.id } }), before + 1); }); test('bulk email transport never exposes another recipient in To', async () => { const sent = []; const oldKey = process.env.RESEND_API_KEY; const oldFrom = process.env.EMAIL_FROM; process.env.RESEND_API_KEY = 'test-only'; process.env.EMAIL_FROM = 'test@example.invalid'; try { const mail = loader({ resend: { Resend: class { emails = { send: async input => { sent.push(input); return { data: { id: 'test' } }; } }; } } })('src/lib/mail.ts'); assert.equal((await mail.sendAppEmail({ to: ['a@example.invalid','b@example.invalid','a@example.invalid'], subject: 'Test', react: null })).success, true); assert.deepEqual(sent.map(input => input.to), ['a@example.invalid','b@example.invalid']); } finally { if (oldKey === undefined) delete process.env.RESEND_API_KEY; else process.env.RESEND_API_KEY = oldKey; if (oldFrom === undefined) delete process.env.EMAIL_FROM; else process.env.EMAIL_FROM = oldFrom; } }); test('read receipts cannot link users and announcements across tenants', async () => { const announcement = await prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentA.id, title: 'Tenant A', content: 'Private' } }); await assert.rejects(prisma.announcementRead.create({ data: { kitaId: kitaA.id, userId: parentB.id, announcementId: announcement.id } }), error => error.code === 'P2003'); await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { kitaId: null } }), error => error.message.includes('users_family_requires_tenant')); }); test('participant secrets are removed from props delivered to unauthorized browsers', async () => { const secret = 'CONFIDENTIAL-PARTICIPANT-' + prefix; const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Private details', type: 'SONSTIGES', status: 'CONFIRMED', requiresRsvp: true, participantInfo: secret, startDate: new Date(Date.now() + 86400000), endDate: new Date(Date.now() + 86400000 + 3600000) } }); const mockComponents = { './_components/admin-termin-modal': { AdminTerminModal: () => null }, './_components/pending-anfragen': { PendingAnfragen: () => null }, './_components/termin-request-modal': { TerminRequestModal: () => null }, './_components/calendar-overview-client': { CalendarOverviewClient: () => null } }; const page = user => loader({ ...mockComponents, '@/auth': { auth: async () => session(user) } })('src/app/dashboard/kalender/page.tsx').default({ searchParams: Promise.resolve({}) }); assert.equal(JSON.stringify(await page(parentA)).includes(secret), false); await prisma.eventParticipation.create({ data: { kitaId: kitaA.id, childId: childA.id, eventId: event.id, status: 'ATTENDING' } }); assert.equal(JSON.stringify(await page(parentA)).includes(secret), true); 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({ '@/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.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.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); }); test('parallel swap creation and acceptance cannot duplicate or undo a swap', async () => { const otherFamily = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'Swap partner' } }); const partner = await prisma.user.create({ data: { email: `partner.${prefix}@example.invalid`, firstName: 'Partner', lastName: 'Test', kitaId: kitaA.id, familyId: otherFamily.id, role: 'ELTERN', passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } }); const partnerChild = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: otherFamily.id, firstName: 'Partner', lastName: 'Test' } }); const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 2, status: 'PUBLISHED' } }); const offered = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 2 * 86400000) } }); const target = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: partnerChild.id, date: new Date(Date.now() + 3 * 86400000) } }); const actions = user => loader({ '@/lib/mail': emailMock, '@/auth': { auth: async () => session(user) } })('src/app/dashboard/kalender/actions.ts'); const requester = actions(parentA); const recipient = actions(partner); const payload = { targetAssignmentId: target.id, offeredAssignmentId: offered.id }; const created = await Promise.all([requester.createNotdienstSwapRequest(payload), requester.createNotdienstSwapRequest(payload)]); assert.equal(created.filter(result => result.success).length, 1); const request = await prisma.notdienstSwapRequest.findFirst({ where: { kitaId: kitaA.id, targetAssignmentId: target.id } }); assert.ok((await actions(parentB).acceptNotdienstSwapRequest(request.id)).error); const accepted = await Promise.all([recipient.acceptNotdienstSwapRequest(request.id), recipient.acceptNotdienstSwapRequest(request.id)]); assert.equal(accepted.filter(result => result.success).length, 1); assert.equal((await prisma.notdienstAssignment.findUnique({ where: { id: target.id } })).childId, childA.id); assert.equal((await prisma.notdienstAssignment.findUnique({ where: { id: offered.id } })).childId, partnerChild.id); assert.ok((await requester.cancelNotdienstSwapRequest(request.id)).error); }); test('changing a parent email revokes sessions and requires fresh mailbox activation', async () => { const newEmail = `changed.${prefix}@example.invalid`; const load = loader({ '@/auth': { auth: async () => session(adminA), signIn: async () => {} }, '@/lib/mail': emailMock }); const actions = load('src/app/dashboard/families/actions.ts'); const oldToken = await authConfig.callbacks.jwt({ token: { id: parentA.id, sessionVersion: parentA.sessionVersion, authenticatedAt: Date.now() } }); assert.equal((await actions.updateFamilyAction({ familyId: familyA.id, familyName: 'Family A', parents: [{ id: parentA.id, firstName: 'Parent', lastName: 'Test', email: newEmail }], children: [{ id: childA.id, firstName: 'A', lastName: 'Test' }] })).success, true); const changed = await prisma.user.findUnique({ where: { id: parentA.id } }); assert.equal(changed.emailVerifiedAt,null); assert.equal(changed.passwordHash,''); assert.equal(await authConfig.callbacks.jwt({ token: oldToken }),null); assert.equal(await authConfig.providers[0].authorize({ email: newEmail, password: 'Safe-test-password' }),null); const initialInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1); await prisma.invitation.update({ where: { userId: parentA.id }, data: { sentAt: new Date(Date.now() - 61_000) } }); assert.equal((await actions.resendParentInvite(parentA.id)).success,true); const renewedInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1); assert.notEqual(initialInvite,renewedInvite); const accept = load('src/app/invite/[token]/actions.ts').acceptInviteAction; const form = token => fields({ token, password: 'New-owner-password', confirmPassword: 'New-owner-password', acceptPrivacyPolicy: 'on' }); assert.ok((await accept({ attempt: 0 }, form(initialInvite))).errors); assert.equal((await accept({ attempt: 0 }, form(renewedInvite))).errors,undefined); assert.ok((await prisma.user.findUnique({ where: { id: parentA.id } })).emailVerifiedAt); }); test('family admins keep their role when a changed mailbox is invited and activated', async () => { const family = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'Admin family' } }); const child = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: family.id, firstName: 'Admin', lastName: 'Child' } }); const user = await prisma.user.create({ data: { email: `family-admin.${prefix}@example.invalid`, firstName: 'Family', lastName: 'Admin', role: 'ADMIN', kitaId: kitaA.id, familyId: family.id, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } }); try { const load = loader({ '@/auth': { auth: async () => session(adminA), signIn: async () => {} }, '@/lib/mail': emailMock }); const actions = load('src/app/dashboard/families/actions.ts'); const updated = await actions.updateFamilyAction({ familyId: family.id, familyName: 'Family A', parents: [{ id: user.id, firstName: 'Family', lastName: 'Admin', email: `changed-family-admin.${prefix}@example.invalid` }], children: [{ id: child.id, firstName: 'A', lastName: 'Test' }] }); assert.equal(updated.success,true,updated.error); assert.equal(updated.warning,undefined); assert.equal((await prisma.user.findUnique({ where: { id: user.id } })).role,'ADMIN'); await prisma.invitation.update({ where: { userId: user.id }, data: { sentAt: new Date(Date.now() - 61_000) } }); assert.equal((await actions.resendParentInvite(user.id)).success,true); const token = emails.at(-1).react.props.inviteLink.split('/').at(-1); const accepted = await load('src/app/invite/[token]/actions.ts').acceptInviteAction({ attempt: 0 }, fields({ token, password: 'Family-admin-new-password', confirmPassword: 'Family-admin-new-password', acceptPrivacyPolicy: 'on' })); assert.equal(accepted.errors,undefined); const activated = await prisma.user.findUnique({ where: { id: user.id } }); assert.equal(activated.role,'ADMIN'); assert.ok(activated.emailVerifiedAt); } finally { await prisma.verificationToken.deleteMany({ where: { identifier: user.id } }); await prisma.family.delete({ where: { id: family.id } }); } }); test('resending an invite cannot issue an active link to an obsolete mailbox', async () => { const user = await prisma.user.create({ data: { email: `resend-race.${prefix}@example.invalid`, firstName: 'Race', lastName: 'Test', role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } }); // Interleave a committed address change after the resend's initial lookup. const guardedPrisma = new Proxy(prisma, { get(target, property) { if (property === '$transaction') return async operation => { await prisma.user.update({ where: { id: user.id }, data: { email: `new-resend-race.${prefix}@example.invalid` } }); return prisma.$transaction(operation); }; const value = target[property]; return typeof value === 'function' ? value.bind(target) : value; } }); const load = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock, '@/lib/prisma': { prisma: guardedPrisma } }); const before = emails.length; assert.ok((await load('src/app/dashboard/families/actions.ts').resendParentInvite(user.id)).error); assert.equal(emails.length,before); assert.equal(await prisma.verificationToken.count({ where: { identifier: user.id } }),0); }); test('upload quota holds under concurrent writes and accepted MIME comes from file bytes', async () => { const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXZQAAAAASUVORK5CYII=', 'base64'); const oldQuota = process.env.UPLOAD_TENANT_QUOTA_BYTES; process.env.UPLOAD_TENANT_QUOTA_BYTES = String(bytes.length * 2); const load = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock }); const create = load('src/actions/announcements.ts').createAnnouncement; const form = () => { const result = fields({ title: 'Quota test', content: 'Safe contents', audience: 'ALLE' }); result.append('attachments',new File([bytes],'test.png',{ type: 'image/png' })); return result; }; try { const results = await Promise.all([create(form()), create(form()), create(form())]); assert.equal(results.filter(result => result.success).length,2); const used = await prisma.attachment.aggregate({ where: { announcement: { kitaId: kitaA.id } }, _sum: { sizeBytes: true } }); assert.equal(used._sum.sizeBytes,bytes.length * 2); const bad = fields({ title: 'Quota bad', content: 'Unsafe contents' }); bad.append('attachments',new File(['bad'],'bad.jpg',{ type: 'text/html' })); assert.ok((await create(bad)).error); } finally { const files = await prisma.attachment.findMany({ where: { announcement: { kitaId: kitaA.id, title: 'Quota test' } }, select: { id: true } }); await load('src/lib/attachments.ts').removeAttachmentFiles(files.map(file => file.id)); await prisma.announcement.deleteMany({ where: { kitaId: kitaA.id, title: 'Quota test' } }); if (oldQuota === undefined) delete process.env.UPLOAD_TENANT_QUOTA_BYTES; else process.env.UPLOAD_TENANT_QUOTA_BYTES = oldQuota; } }); 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,45); }); test('role guard authenticates once and keeps role validation on that session', async () => { let calls = 0; const util = loader({ '@/auth': { auth: async () => { calls++; return session(calls === 1 ? adminA : parentB); } } })('src/lib/auth-utils.ts'); assert.equal((await util.requireRole([UserRole.ADMIN])).user.id,adminA.id); assert.equal(calls,1); }); test('parallel alarm triggers send one set of messages and skip unverified recipients', async () => { const unverified = await prisma.user.create({ data: { email: `unverified.${prefix}@example.invalid`, firstName: 'Unverified', lastName: 'Test', role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '', emailVerifiedAt: null } }); const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 3, 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 trigger = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock })('src/app/dashboard/actions.ts').triggerAlertAction; 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.notdienstAlarmRecipient.findMany({ where: { alarm: { assignmentId: assignment.id } } }); assert.ok(alerts.length > 0); 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' } })); });