Files
harheimertc/tests/config-profile-endpoints.spec.ts
Torsten Schulz (local) 5da11d2e4d
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Fix in news, first android notification service
2026-06-10 13:47:33 +02:00

395 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})
})
})