import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createEvent, mockSuccessReadBody } from './setup' import { readFileSync } from 'fs' import crypto from 'crypto' vi.mock('../server/utils/auth.js', () => { return { readUsers: vi.fn(), writeUsers: vi.fn(), verifyPassword: vi.fn(), generateToken: vi.fn(), generateAndroidAccessToken: vi.fn(), createSession: vi.fn(), createRefreshSession: vi.fn(), rotateRefreshSession: vi.fn(), revokeRefreshSession: vi.fn(), revokeRefreshSessionsForUser: vi.fn(), getUserById: vi.fn(), hashPassword: vi.fn(), verifyToken: vi.fn(), deleteSession: vi.fn(), getUserFromToken: vi.fn(), readSessions: vi.fn(), writeSessions: vi.fn(), migrateUserRoles: vi.fn((user) => { if (!user) return user if (Array.isArray(user.roles)) return user if (user.role) { user.roles = [user.role] delete user.role } else { user.roles = ['mitglied'] } return user }), hasRole: vi.fn((user, role) => { if (!user) return false const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return roles.includes(role) }), hasAnyRole: vi.fn((user, ...roles) => { if (!user) return false const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return roles.some(r => userRoles.includes(r)) }), hasAllRoles: vi.fn((user, ...roles) => { if (!user) return false const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return roles.every(r => userRoles.includes(r)) }) } }) vi.mock('nodemailer', () => { const sendMail = vi.fn().mockResolvedValue(true) const createTransport = vi.fn(() => ({ sendMail })) return { default: { createTransport }, createTransport } }) vi.mock('../server/utils/password-reset-log.js', () => ({ normalizeResetEmail: vi.fn(email => String(email || '').trim().toLowerCase()), maskResetEmail: vi.fn(email => `masked:${String(email || '').trim().toLowerCase()}`), writePasswordResetLog: vi.fn().mockResolvedValue(undefined) })) const authUtils = await import('../server/utils/auth.js') const nodemailer = await import('nodemailer') const passwordResetLog = await import('../server/utils/password-reset-log.js') import loginHandler from '../server/api/auth/login.post.js' import logoutHandler from '../server/api/auth/logout.post.js' import refreshHandler from '../server/api/auth/refresh.post.js' import registerHandler from '../server/api/auth/register.post.js' import resetPasswordHandler from '../server/api/auth/reset-password.post.js' import completePasswordResetHandler from '../server/api/auth/reset-password/complete.post.js' import statusHandler from '../server/api/auth/status.get.js' import versionHandler from '../server/api/app/version.get.js' describe('Auth API Endpoints', () => { afterEach(() => { delete process.env.NODE_ENV delete process.env.APP_ENV delete process.env.NUXT_PUBLIC_BASE_URL delete process.env.PASSWORD_RESET_TTL_MIN }) beforeEach(() => { // Setze SMTP-Credentials für Tests process.env.SMTP_USER = 'test@example.com' process.env.SMTP_PASS = 'test-password' process.env.NUXT_PUBLIC_BASE_URL = 'https://harheimertc.de' vi.clearAllMocks() }) describe('POST /api/auth/login', () => { it('wirft 400 bei fehlenden Feldern', async () => { const event = createEvent() mockSuccessReadBody({}) await expect(loginHandler(event)).rejects.toMatchObject({ statusCode: 400 }) }) it('wirft 401 bei unbekanntem Benutzer', async () => { const event = createEvent() mockSuccessReadBody({ email: 'test@example.com', password: 'password' }) authUtils.readUsers.mockResolvedValue([]) await expect(loginHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('gibt Token und Cookie bei Erfolg zurück', async () => { const event = createEvent() const user = { id: '1', email: 'test@example.com', password: 'hash', role: 'mitglied', active: true, lastLogin: null } mockSuccessReadBody({ email: user.email, password: 'plain' }) authUtils.readUsers.mockResolvedValue([user]) authUtils.verifyPassword.mockResolvedValue(true) authUtils.generateToken.mockReturnValue('jwt-token') authUtils.createSession.mockResolvedValue({}) authUtils.writeUsers.mockResolvedValue(true) const response = await loginHandler(event) expect(response.success).toBe(true) expect(response.token).toBe('jwt-token') expect(response.user).toMatchObject({ id: '1', email: user.email }) expect(authUtils.createSession).toHaveBeenCalledWith('1', 'jwt-token') expect(authUtils.writeUsers).toHaveBeenCalled() }) it('gibt Android-Clients ein Refresh-Token für eine Gerätesitzung zurück', async () => { const event = createEvent() const user = { id: '1', email: 'test@example.com', password: 'hash', roles: ['mitglied'], active: true } mockSuccessReadBody({ email: user.email, password: 'plain', client: 'android', deviceName: 'Pixel' }) authUtils.readUsers.mockResolvedValue([user]) authUtils.verifyPassword.mockResolvedValue(true) authUtils.createRefreshSession.mockResolvedValue({ session: { id: 'session-1' }, refreshToken: 'refresh-1' }) authUtils.generateAndroidAccessToken.mockReturnValue('access-1') authUtils.writeUsers.mockResolvedValue(true) const response = await loginHandler(event) expect(authUtils.createRefreshSession).toHaveBeenCalledWith('1', 'Pixel') expect(authUtils.generateAndroidAccessToken).toHaveBeenCalledWith(user, 'session-1') expect(response).toMatchObject({ token: 'access-1', accessToken: 'access-1', refreshToken: 'refresh-1', sessionId: 'session-1' }) expect(authUtils.createSession).not.toHaveBeenCalled() }) }) describe('POST /api/auth/logout', () => { it('löscht Session und Cookie, wenn Token vorhanden ist', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.deleteSession.mockResolvedValue() const response = await logoutHandler(event) expect(response.success).toBe(true) expect(authUtils.deleteSession).toHaveBeenCalledWith('token') }) it('wirft 500 bei Fehlern', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.deleteSession.mockRejectedValue(new Error('boom')) await expect(logoutHandler(event)).rejects.toMatchObject({ statusCode: 500 }) }) it('widerruft beim Android-Logout das Refresh-Token', async () => { const event = createEvent({ headers: { authorization: 'Bearer access-token' } }) mockSuccessReadBody({ refreshToken: 'refresh-token' }) authUtils.deleteSession.mockResolvedValue() authUtils.revokeRefreshSession.mockResolvedValue(true) const response = await logoutHandler(event) expect(response.success).toBe(true) expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('refresh-token') }) }) describe('POST /api/auth/refresh', () => { it('rotiert eine gültige Android-Sitzung und gibt ein neues Token-Paar zurück', async () => { const event = createEvent() mockSuccessReadBody({ refreshToken: 'old-refresh' }) const user = { id: '1', email: 'test@example.com', roles: ['mitglied'], active: true } authUtils.rotateRefreshSession.mockResolvedValue({ status: 'rotated', session: { id: 'session-2', userId: '1' }, refreshToken: 'new-refresh' }) authUtils.getUserById.mockResolvedValue(user) authUtils.generateAndroidAccessToken.mockReturnValue('new-access') const response = await refreshHandler(event) expect(authUtils.rotateRefreshSession).toHaveBeenCalledWith('old-refresh') expect(response).toMatchObject({ accessToken: 'new-access', refreshToken: 'new-refresh', sessionId: 'session-2' }) }) it('weist widerrufene oder erneut verwendete Refresh-Tokens zurück', async () => { const event = createEvent() mockSuccessReadBody({ refreshToken: 'used-refresh' }) authUtils.rotateRefreshSession.mockResolvedValue({ status: 'reused' }) await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('widerruft eine rotierte Sitzung, wenn der Benutzer nicht mehr aktiv ist', async () => { const event = createEvent() mockSuccessReadBody({ refreshToken: 'old-refresh' }) authUtils.rotateRefreshSession.mockResolvedValue({ status: 'rotated', session: { id: 'session-2', userId: '1' }, refreshToken: 'new-refresh' }) authUtils.getUserById.mockResolvedValue({ id: '1', active: false }) await expect(refreshHandler(event)).rejects.toMatchObject({ statusCode: 401 }) expect(authUtils.revokeRefreshSession).toHaveBeenCalledWith('new-refresh', 'inactive_or_missing_user') }) }) describe('POST /api/auth/register', () => { it('prüft Pflichtfelder', async () => { const event = createEvent() mockSuccessReadBody({}) await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 400 }) }) it('verhindert doppelte Benutzer', async () => { const event = createEvent() mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678', geburtsdatum: '2000-01-01' }) authUtils.readUsers.mockResolvedValue([{ email: 'max@example.com' }]) await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 409 }) }) it('verlangt Geburtsdatum bei Registrierung', async () => { const event = createEvent() mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678' }) await expect(registerHandler(event)).rejects.toMatchObject({ statusCode: 400 }) }) it('legt Benutzer an und versendet E-Mails', async () => { const event = createEvent() mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678', phone: '123', geburtsdatum: '2000-01-01', visibility: { showBirthday: false } }) authUtils.readUsers.mockResolvedValue([]) authUtils.hashPassword.mockResolvedValue('hashed') authUtils.writeUsers.mockResolvedValue(true) const response = await registerHandler(event) expect(response.success).toBe(true) expect(authUtils.writeUsers).toHaveBeenCalled() expect(authUtils.writeUsers.mock.calls[0][0][0]).toMatchObject({ geburtsdatum: '2000-01-01', visibility: { showBirthday: false } }) expect(nodemailer.default.createTransport).toHaveBeenCalled() }) it('benachrichtigt in Testumgebung nicht die Vorstand-Empfänger', async () => { process.env.NODE_ENV = 'production' process.env.APP_ENV = 'test' const event = createEvent() mockSuccessReadBody({ name: 'Max', email: 'max@example.com', password: '12345678', phone: '123', geburtsdatum: '2000-01-01' }) authUtils.readUsers.mockResolvedValue([]) authUtils.hashPassword.mockResolvedValue('hashed') authUtils.writeUsers.mockResolvedValue(true) await registerHandler(event) const transporter = nodemailer.default.createTransport.mock.results[0].value expect(transporter.sendMail).toHaveBeenNthCalledWith(1, expect.objectContaining({ to: 'tsschulz@tsschulz.de' })) }) }) describe('POST /api/auth/reset-password', () => { it('prüft Pflichtfelder ohne öffentliche Fehlermeldung', async () => { const event = createEvent() mockSuccessReadBody({}) const response = await resetPasswordHandler(event) expect(response.success).toBe(true) }) it('speichert einen gehashten Reset-Token und lässt das alte Passwort unverändert', async () => { const event = createEvent() const user = { id: '1', email: 'user@example.com', name: 'User', password: 'old-hash' } mockSuccessReadBody({ email: user.email }) authUtils.readUsers.mockResolvedValue([user]) authUtils.writeUsers.mockResolvedValue(true) const response = await resetPasswordHandler(event) expect(response.success).toBe(true) expect(authUtils.hashPassword).not.toHaveBeenCalled() expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled() const writtenUser = authUtils.writeUsers.mock.calls[0][0][0] expect(writtenUser.password).toBe('old-hash') expect(writtenUser.passwordResetTokens).toHaveLength(1) expect(writtenUser.passwordResetTokens[0]).toMatchObject({ usedAt: null }) expect(writtenUser.passwordResetTokens[0].tokenHash).toMatch(/^[a-f0-9]{64}$/) const transporter = nodemailer.default.createTransport.mock.results[0].value expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ html: expect.stringContaining('https://harheimertc.de/passwort-zuruecksetzen?token=') })) expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com', step: 'mail_send', status: 'completed' })) }) it('normalisiert Leerzeichen bei der Benutzersuche', async () => { const event = createEvent() const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } mockSuccessReadBody({ email: ' User@Example.com ' }) authUtils.readUsers.mockResolvedValue([user]) authUtils.writeUsers.mockResolvedValue(true) await resetPasswordHandler(event) expect(authUtils.writeUsers).toHaveBeenCalled() expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ') }) it('ändert nichts, wenn SMTP nicht konfiguriert ist', async () => { const event = createEvent() mockSuccessReadBody({ email: 'user@example.com' }) authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }]) delete process.env.SMTP_USER delete process.env.SMTP_PASS const response = await resetPasswordHandler(event) expect(response.success).toBe(true) expect(authUtils.writeUsers).not.toHaveBeenCalled() expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({ step: 'mail_configuration', status: 'failed', reason: 'smtp_credentials_missing' })) }) it('protokolliert einen Mailfehler ohne das Passwort zu ersetzen', async () => { const event = createEvent() const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' } mockSuccessReadBody({ email: 'user@example.com' }) authUtils.readUsers.mockResolvedValue([user]) authUtils.writeUsers.mockResolvedValue(true) nodemailer.default.createTransport.mockReturnValueOnce({ sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' })) }) const response = await resetPasswordHandler(event) expect(response.success).toBe(true) expect(authUtils.hashPassword).not.toHaveBeenCalled() expect(authUtils.writeUsers.mock.calls[0][0][0].password).toBe('hash') expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({ step: 'mail_send', status: 'failed' })) }) }) describe('POST /api/auth/reset-password/complete', () => { it('setzt ein neues Passwort mit gültigem Reset-Token', async () => { const token = 'reset-token' const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest('hex') const event = createEvent() const user = { id: '1', email: 'user@example.com', password: 'old-hash', passwordResetRequired: true, passwordResetTokens: [{ tokenHash, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60000).toISOString(), usedAt: null }] } mockSuccessReadBody({ token, password: 'new-password' }) authUtils.readUsers.mockResolvedValue([user]) authUtils.hashPassword.mockResolvedValue('new-hash') authUtils.writeUsers.mockResolvedValue(true) const response = await completePasswordResetHandler(event) expect(response.success).toBe(true) expect(authUtils.hashPassword).toHaveBeenCalledWith('new-password') expect(authUtils.writeUsers.mock.calls[0][0][0]).toMatchObject({ password: 'new-hash', passwordResetRequired: false }) expect(authUtils.writeUsers.mock.calls[0][0][0].passwordResetTokens[0].usedAt).toEqual(expect.any(String)) expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset_completed') }) it('weist abgelaufene Reset-Tokens zurück', async () => { const token = 'reset-token' const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest('hex') const event = createEvent() mockSuccessReadBody({ token, password: 'new-password' }) authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', password: 'old-hash', passwordResetTokens: [{ tokenHash, expiresAt: new Date(Date.now() - 60000).toISOString(), usedAt: null }] }]) await expect(completePasswordResetHandler(event)).rejects.toMatchObject({ statusCode: 400 }) expect(authUtils.writeUsers).not.toHaveBeenCalled() expect(authUtils.hashPassword).not.toHaveBeenCalled() }) it('verlangt mindestens acht Zeichen für das neue Passwort', async () => { const event = createEvent() mockSuccessReadBody({ token: 'reset-token', password: 'kurz' }) await expect(completePasswordResetHandler(event)).rejects.toMatchObject({ statusCode: 400 }) expect(authUtils.readUsers).not.toHaveBeenCalled() }) }) describe('GET /api/auth/status', () => { it('liefert loggedOut, wenn kein Cookie gesetzt ist', async () => { const event = createEvent() const response = await statusHandler(event) expect(response.isLoggedIn).toBe(false) }) it('liefert Benutzerinformationen bei gültigem Token', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.getUserFromToken.mockResolvedValue({ id: '1', email: 'user@example.com', name: 'User', role: 'mitglied' }) const response = await statusHandler(event) expect(response.isLoggedIn).toBe(true) expect(response.user).toMatchObject({ id: '1' }) }) }) describe('GET /api/app/version', () => { it('verlangt Login', async () => { const event = createEvent() await expect(versionHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('liefert eingeloggten Benutzern die package.json-Version', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.getUserFromToken.mockResolvedValue({ id: '1', email: 'user@example.com', roles: ['mitglied'] }) const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) const response = await versionHandler(event) expect(response.version).toBe(packageJson.version) }) }) })