import { beforeEach, describe, expect, it, vi } from 'vitest' import { createEvent, mockSuccessReadBody } from './setup' import fs from 'fs/promises' vi.mock('../server/utils/auth.js', () => ({ verifyToken: vi.fn(), getUserById: vi.fn(), getUserFromToken: vi.fn(), readUsers: vi.fn(), writeUsers: vi.fn(), verifyPassword: vi.fn(), hashPassword: vi.fn(), revokeRefreshSessionsForUser: 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 }), 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)) }) })) vi.mock('../server/utils/hibp.js', () => ({ assertPasswordNotPwned: vi.fn().mockResolvedValue(undefined) })) const authUtils = await import('../server/utils/auth.js') const hibpUtils = await import('../server/utils/hibp.js') import configGetHandler from '../server/api/config.get.js' import configPutHandler from '../server/api/config.put.js' import profileGetHandler from '../server/api/profile.get.js' import profilePutHandler from '../server/api/profile.put.js' import profileNotificationsGetHandler from '../server/api/profile/notifications.get.js' import profileNotificationsPutHandler from '../server/api/profile/notifications.put.js' import profilePushTokenHandler from '../server/api/profile/push-token.post.js' const invalidCurrentPassword = ['invalid', 'test', 'pw'].join('-') const validCurrentPassword = ['valid', 'test', 'pw'].join('-') const updatedPassword = ['updated', 'profile', 'pw'].join('-') describe('Config & Profil Endpoints', () => { beforeEach(() => { vi.clearAllMocks() vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ title: 'Test-Konfiguration' })) vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined) hibpUtils.assertPasswordNotPwned.mockResolvedValue(undefined) }) describe('GET /api/config', () => { it('gibt Konfiguration zurück (kein Login nötig)', async () => { const event = createEvent() const result = await configGetHandler(event) expect(result).toMatchObject({ title: 'Test-Konfiguration' }) }) it('gibt 500 zurück wenn Config-Datei fehlt', async () => { vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) const event = createEvent() await expect(configGetHandler(event)).rejects.toMatchObject({ statusCode: 500 }) }) }) describe('PUT /api/config', () => { it('verlangt Authentifizierung', async () => { const event = createEvent() mockSuccessReadBody({ title: 'Neu' }) await expect(configPutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('lehnt ungültiges Token ab', async () => { const event = createEvent({ cookies: { auth_token: 'bad' } }) authUtils.verifyToken.mockReturnValue(null) mockSuccessReadBody({ title: 'Neu' }) await expect(configPutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('verlangt Admin- oder Vorstand-Rolle', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserById.mockResolvedValue({ id: '1', roles: ['mitglied'] }) authUtils.hasAnyRole.mockReturnValue(false) mockSuccessReadBody({ title: 'Neu' }) await expect(configPutHandler(event)).rejects.toMatchObject({ statusCode: 403 }) }) it('speichert Konfiguration erfolgreich', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserById.mockResolvedValue({ id: '1', roles: ['admin'] }) authUtils.hasAnyRole.mockReturnValue(true) mockSuccessReadBody({ title: 'Neue Konfig', kontakt: { email: 'test@test.de' } }) const result = await configPutHandler(event) expect(result.success).toBe(true) expect(fs.writeFile).toHaveBeenCalled() }) }) describe('GET /api/profile', () => { it('verlangt Authentifizierung', async () => { const event = createEvent() await expect(profileGetHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('lehnt ungültiges Token ab', async () => { const event = createEvent({ cookies: { auth_token: 'bad' } }) authUtils.verifyToken.mockReturnValue(null) await expect(profileGetHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('gibt 404 wenn Benutzer nicht gefunden', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserFromToken.mockResolvedValue(null) await expect(profileGetHandler(event)).rejects.toMatchObject({ statusCode: 404 }) }) it('liefert Profildaten', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserFromToken.mockResolvedValue({ id: '1', name: 'Max Muster', email: 'max@test.de', phone: '0123456789', geburtsdatum: '1990-01-01' }) const result = await profileGetHandler(event) expect(result.success).toBe(true) expect(result.user.name).toBe('Max Muster') expect(result.user.email).toBe('max@test.de') expect(result.user).not.toHaveProperty('password') }) it('akzeptiert Bearer-Token für Android-Clients', async () => { const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserFromToken.mockResolvedValue({ id: '1', name: 'Android User', email: 'android@test.de', roles: ['mitglied'] }) const result = await profileGetHandler(event) expect(result.success).toBe(true) expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token') expect(result.user.email).toBe('android@test.de') }) }) describe('GET /api/profile/notifications', () => { it('verlangt Authentifizierung', async () => { const event = createEvent() await expect(profileNotificationsGetHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('liefert Defaults plus gespeicherte Benachrichtigungseinstellungen', async () => { const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserFromToken.mockResolvedValue({ id: '1', notificationSettings: { eventsToday: true, selectedTeamSlugs: ['herren-1', 'herren-1', ''], selectedTeamSeason: '2025/2026', notificationTime: '07:30' } }) const result = await profileNotificationsGetHandler(event) expect(result.success).toBe(true) expect(result.settings.eventsToday).toBe(true) expect(result.settings.newEvents).toBe(false) expect(result.settings.selectedTeamSlugs).toEqual(['herren-1']) expect(result.settings.notificationTime).toBe('07:30') }) }) describe('PUT /api/profile/notifications', () => { it('verlangt Authentifizierung', async () => { const event = createEvent({ body: { eventsToday: true } }) await expect(profileNotificationsPutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('speichert sanitizte Benachrichtigungseinstellungen am Benutzer', async () => { const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) mockSuccessReadBody({ newEvents: true, eventsToday: 'true', birthdays: true, selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '], selectedTeamSeason: '2026/2027', notificationTime: '25:99' }) const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }] authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserFromToken.mockResolvedValue(users[0]) authUtils.readUsers.mockResolvedValue(users) authUtils.writeUsers.mockResolvedValue(true) const result = await profileNotificationsPutHandler(event) expect(result.success).toBe(true) expect(result.settings.newEvents).toBe(true) expect(result.settings.eventsToday).toBe(false) expect(result.settings.birthdays).toBe(true) expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend']) expect(result.settings.notificationTime).toBe('09:00') expect(authUtils.writeUsers).toHaveBeenCalledWith([ expect.objectContaining({ id: '1', notificationSettings: expect.objectContaining({ newEvents: true, birthdays: true, selectedTeamSeason: '2026/2027' }) }) ]) }) }) describe('POST /api/profile/push-token', () => { it('verlangt Authentifizierung', async () => { const event = createEvent() mockSuccessReadBody({ token: 'fcm-token' }) await expect(profilePushTokenHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('speichert Android-Push-Token am Benutzer', async () => { const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' }) const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }] authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.getUserFromToken.mockResolvedValue(users[0]) authUtils.readUsers.mockResolvedValue(users) authUtils.writeUsers.mockResolvedValue(true) const result = await profilePushTokenHandler(event) expect(result.success).toBe(true) expect(authUtils.writeUsers).toHaveBeenCalledWith([ expect.objectContaining({ id: '1', pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })] }) ]) }) }) describe('PUT /api/profile', () => { it('verlangt Authentifizierung', async () => { const event = createEvent() mockSuccessReadBody({ name: 'Max', email: 'max@test.de' }) await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('lehnt ungültiges Token ab', async () => { const event = createEvent({ cookies: { auth_token: 'bad' } }) authUtils.verifyToken.mockReturnValue(null) mockSuccessReadBody({ name: 'Max', email: 'max@test.de' }) await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('validiert Pflichtfelder – Name fehlt', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) mockSuccessReadBody({ email: 'max@test.de' }) await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 400 }) }) it('gibt 404 wenn Benutzer nicht gefunden', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '99' }) authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'other@test.de', password: 'h' }]) mockSuccessReadBody({ name: 'Max', email: 'max@test.de' }) await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 404 }) }) it('verhindert E-Mail-Duplikat', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.readUsers.mockResolvedValue([ { id: '1', name: 'Max', email: 'max@test.de', password: 'hash', roles: ['mitglied'] }, { id: '2', name: 'Anna', email: 'anna@test.de', password: 'hash2', roles: ['mitglied'] } ]) mockSuccessReadBody({ name: 'Max', email: 'anna@test.de' }) await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 409 }) }) it('aktualisiert Profil ohne Passwortänderung', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.readUsers.mockResolvedValue([ { id: '1', name: 'Alt', email: 'max@test.de', password: 'hash', roles: ['mitglied'] } ]) authUtils.writeUsers.mockResolvedValue(undefined) authUtils.migrateUserRoles.mockImplementation(u => ({ ...u, roles: u.roles || ['mitglied'] })) mockSuccessReadBody({ name: 'Max Neu', email: 'max@test.de', phone: '0987' }) const result = await profilePutHandler(event) expect(result.success).toBe(true) expect(result.user.name).toBe('Max Neu') expect(authUtils.writeUsers).toHaveBeenCalled() expect(authUtils.revokeRefreshSessionsForUser).not.toHaveBeenCalled() }) it('aktualisiert Profil per Bearer-Token für Android-Clients', async () => { const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.readUsers.mockResolvedValue([ { id: '1', name: 'Alt', email: 'max@test.de', password: 'hash', roles: ['mitglied'] } ]) authUtils.writeUsers.mockResolvedValue(undefined) authUtils.migrateUserRoles.mockImplementation(u => ({ ...u, roles: u.roles || ['mitglied'] })) mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de', phone: '0987' }) const result = await profilePutHandler(event) expect(result.success).toBe(true) expect(authUtils.verifyToken).toHaveBeenCalledWith('android-token') expect(result.user.name).toBe('Android Neu') }) it('lehnt widerrufene Android-Sessions beim Profil-Update ab', async () => { const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) authUtils.verifyToken.mockReturnValue({ id: '1', sid: 'session-1' }) authUtils.getUserFromToken.mockResolvedValue(null) mockSuccessReadBody({ name: 'Android Neu', email: 'android@test.de' }) await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('prüft aktuelles Passwort bei Passwortänderung', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.readUsers.mockResolvedValue([ { id: '1', name: 'Max', email: 'max@test.de', password: 'hash', roles: ['mitglied'] } ]) authUtils.verifyPassword.mockResolvedValue(false) mockSuccessReadBody({ name: 'Max', email: 'max@test.de', currentPassword: invalidCurrentPassword, newPassword: updatedPassword }) await expect(profilePutHandler(event)).rejects.toMatchObject({ statusCode: 401 }) }) it('aktualisiert Passwort erfolgreich', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.readUsers.mockResolvedValue([ { id: '1', name: 'Max', email: 'max@test.de', password: 'altes-hash', roles: ['mitglied'] } ]) authUtils.verifyPassword.mockResolvedValue(true) authUtils.hashPassword.mockResolvedValue('neues-hash') authUtils.writeUsers.mockResolvedValue(undefined) authUtils.migrateUserRoles.mockImplementation(u => ({ ...u, roles: u.roles || ['mitglied'] })) mockSuccessReadBody({ name: 'Max', email: 'max@test.de', currentPassword: validCurrentPassword, newPassword: updatedPassword }) const result = await profilePutHandler(event) expect(result.success).toBe(true) expect(authUtils.hashPassword).toHaveBeenCalledWith(updatedPassword) expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_changed') }) }) })