Harden authentication, tenant isolation and security boundaries
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Standalone Node integration test. */
|
||||
const assert = require('node:assert/strict');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { hash } = require('bcryptjs');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const origin = 'http://localhost:3057';
|
||||
const testUrl = process.env.SECURITY_TEST_DATABASE_URL;
|
||||
if (!testUrl || !['127.0.0.1','localhost'].includes(new URL(testUrl).hostname) || !new URL(testUrl).pathname.endsWith('/kita_security_test')) throw new Error('Use only the dedicated local test database.');
|
||||
const prisma = new PrismaClient({ datasourceUrl: testUrl });
|
||||
const prefix = `http-security-${randomUUID()}`;
|
||||
let attachmentId;
|
||||
const upload = path.resolve(process.env.UPLOAD_DIR, 'announcements');
|
||||
let checks = 0;
|
||||
function checked() { checks++; }
|
||||
async function login(email, password) {
|
||||
const jar = new Map();
|
||||
function cookies(response) {
|
||||
for (const cookie of response.headers.getSetCookie()) {
|
||||
const item = cookie.split(';')[0]; const index = item.indexOf('=');
|
||||
jar.set(item.slice(0,index), item.slice(index + 1));
|
||||
}
|
||||
}
|
||||
const csrf = await fetch(origin + '/api/auth/csrf'); cookies(csrf);
|
||||
const { csrfToken } = await csrf.json();
|
||||
const response = await fetch(origin + '/api/auth/callback/credentials', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: origin,
|
||||
Cookie: [...jar].map(([key,value]) => `${key}=${value}`).join('; '), 'X-Auth-Return-Redirect': '1' },
|
||||
body: new URLSearchParams({ csrfToken, email, password, callbackUrl: origin + '/dashboard' }), redirect: 'manual',
|
||||
});
|
||||
cookies(response);
|
||||
return [...jar].map(([key,value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
async function main() {
|
||||
const password = 'Local-http-test-password';
|
||||
const passwordHash = await hash(password, 12);
|
||||
const kitaA = await prisma.kita.create({ data: { name: prefix + '-A', slug: prefix + '-a' } });
|
||||
const kitaB = await prisma.kita.create({ data: { name: prefix + '-B', slug: prefix + '-b' } });
|
||||
const makeUser = (kita,label) => prisma.user.create({ data: { email: `${label}.${prefix}@example.invalid`, firstName: label, lastName: 'HTTP', kitaId: kita.id,
|
||||
role: 'ADMIN', passwordHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } });
|
||||
const userA = await makeUser(kitaA,'a'); const userB = await makeUser(kitaB,'b');
|
||||
const page = await fetch(origin + '/login');
|
||||
const html = await page.text();
|
||||
assert.equal(page.status, 200); checked();
|
||||
assert.equal(page.headers.get('x-content-type-options'), 'nosniff'); checked();
|
||||
assert.equal(page.headers.get('x-frame-options'), 'DENY'); checked();
|
||||
assert.equal(page.headers.get('referrer-policy'), 'no-referrer'); checked();
|
||||
const csp = page.headers.get('content-security-policy');
|
||||
assert.ok(csp.includes("'strict-dynamic'") && !csp.includes("'unsafe-eval'")); checked();
|
||||
const nonce = csp.match(/'nonce-([^']+)'/)[1];
|
||||
assert.ok(html.includes(`nonce="${nonce}"`)); checked();
|
||||
assert.ok(!html.includes('umami-before-send')); checked();
|
||||
for (const headers of [ {}, { Origin: 'https://evil.invalid' }, { Origin: 'https://evil.invalid', 'x-forwarded-host': 'evil.invalid' } ]) {
|
||||
const blocked = await fetch(origin + '/register', { method: 'POST', headers: { ...headers, 'Next-Action': 'invalid-test-action' }, body: '[]', redirect: 'manual' });
|
||||
assert.equal(blocked.status,403); checked();
|
||||
}
|
||||
for (const url of ['/api/announcements/attachments/invalid', 'https://images.unsplash.com/unknown-image']) {
|
||||
const optimized = await fetch(origin + '/_next/image?' + new URLSearchParams({ url, w: '640', q: '75' }));
|
||||
assert.equal(optimized.status,400); checked();
|
||||
}
|
||||
const cookieA = await login(userA.email,password);
|
||||
const cookieB = await login(userB.email,password);
|
||||
const getSession = async cookie => (await fetch(origin + '/api/auth/session', { headers: { Cookie: cookie } })).json();
|
||||
assert.equal((await getSession(cookieA))?.user.id, userA.id); checked();
|
||||
assert.equal((await fetch(origin + '/admin', { headers: { Cookie: cookieA }, redirect: 'manual' })).status, 307); checked();
|
||||
assert.equal((await fetch(origin + '/dashboard', { redirect: 'manual' })).status, 307); checked();
|
||||
const privatePage = await fetch(origin + '/dashboard', { headers: { Cookie: cookieA }, redirect: 'manual' });
|
||||
assert.equal(privatePage.status, 200); checked();
|
||||
assert.match(privatePage.headers.get('cache-control'), /no-store/); checked();
|
||||
attachmentId = randomUUID();
|
||||
await prisma.announcement.create({ data: { kitaId: kitaB.id, authorId: userB.id, title: 'Private attachment', content: 'Private',
|
||||
attachments: { create: { id: attachmentId, fileName: 'bad.html', fileType: 'text/html', fileUrl: '/api/' + attachmentId } } } });
|
||||
await fs.mkdir(upload, { recursive: true }); await fs.writeFile(path.join(upload,attachmentId), '<script>bad()</script>');
|
||||
const attachment = cookie => fetch(origin + '/api/announcements/attachments/' + attachmentId, { headers: { Cookie: cookie } });
|
||||
assert.equal((await attachment(cookieA)).status, 404); checked();
|
||||
const own = await attachment(cookieB); assert.equal(own.status,200); checked();
|
||||
assert.equal(own.headers.get('content-type'),'application/octet-stream'); checked();
|
||||
assert.match(own.headers.get('content-disposition'),/^attachment;/); checked();
|
||||
assert.equal(own.headers.get('content-security-policy'),"default-src 'none'; sandbox"); checked();
|
||||
await prisma.user.update({ where: { id: userB.id }, data: { privacyPolicyAcceptedAt: null } });
|
||||
assert.equal((await attachment(cookieB)).status,401); checked();
|
||||
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } });
|
||||
assert.equal((await getSession(cookieA))?.user,undefined); checked();
|
||||
await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: null } });
|
||||
assert.equal((await getSession(cookieA))?.user,undefined); checked();
|
||||
const newCookie = await login(userA.email,password);
|
||||
assert.equal((await getSession(newCookie))?.user.id,userA.id); checked();
|
||||
await prisma.user.delete({ where: { id: userA.id } });
|
||||
assert.equal((await getSession(newCookie))?.user,undefined); checked();
|
||||
// Auth.js must reject trusted-but-obsolete token payloads rather than constructing a ghost session.
|
||||
const { encode } = await import('@auth/core/jwt');
|
||||
for (const payload of [{ role: 'ADMIN', kitaId: kitaA.id }, { id: userB.id, role: 'ADMIN', kitaId: kitaB.id },
|
||||
{ id: 123, sessionVersion: 0, authenticatedAt: Date.now() },
|
||||
{ id: 'deleted-' + prefix, role: 'ADMIN', kitaId: kitaA.id, sessionVersion: 0, authenticatedAt: Date.now() }]) {
|
||||
const jwt = await encode({ token: payload, secret: 'security-test-only-not-a-production-secret', salt: 'authjs.session-token' });
|
||||
assert.equal((await getSession('authjs.session-token=' + jwt))?.user,undefined); checked();
|
||||
}
|
||||
console.log(`${checks} HTTP security checks passed against the production build.`);
|
||||
}
|
||||
main().finally(async () => {
|
||||
if (attachmentId) await fs.rm(path.join(upload,attachmentId),{ force: true });
|
||||
await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } });
|
||||
await prisma.$disconnect();
|
||||
}).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,378 @@
|
||||
/* 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 === '@/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;
|
||||
}
|
||||
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(); 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 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() } });
|
||||
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,31);
|
||||
});
|
||||
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.notdienstAlert.findMany({ where: { assignmentId: assignment.id } });
|
||||
assert.ok(alerts.length > 0);
|
||||
assert.equal(alerts.some(alert => alert.parentUserId === unverified.id),false);
|
||||
assert.equal(emails.slice(before).some(email => email.to === unverified.email),false);
|
||||
assert.equal(emails.length - before,alerts.length);
|
||||
});
|
||||
Reference in New Issue
Block a user