402 lines
32 KiB
JavaScript
402 lines
32 KiB
JavaScript
/* 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('<html><script>alert(1)</script></html>')), 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');
|
|
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), '<script>secret()</script>');
|
|
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);
|
|
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('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(['<html>bad</html>'],'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,41);
|
|
});
|
|
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' } }));
|
|
});
|