395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
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')
|
||
})
|
||
})
|
||
})
|