Files
harheimertc/tests/auth-endpoints.spec.ts
Torsten Schulz (local) 58fd7fa5c6
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m7s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
feat(auth): implement Android refresh token handling and session management
- Added support for generating Android access tokens and managing refresh sessions in the auth endpoints.
- Implemented new tests for login, logout, and refresh functionalities specific to Android clients.
- Enhanced password reset logging with normalization and masking of email addresses.
- Created a new diagnostics endpoint for password reset attempts, including filtering and summarizing logs.
- Introduced a new utility for managing password reset logs with retention policies.
- Added tests for password reset log utilities to ensure proper functionality and privacy compliance.
- Updated WebAuthn configuration tests to validate origin handling for production and allowed origins.
2026-05-27 19:34:53 +02:00

418 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup'
import { readFileSync } from 'fs'
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 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
})
beforeEach(() => {
// Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com'
process.env.SMTP_PASS = 'test-password'
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', async () => {
const event = createEvent()
mockSuccessReadBody({})
const response = await resetPasswordHandler(event)
expect(response.success).toBe(true)
})
it('aktualisiert Passwort bei vorhandenem Benutzer', async () => {
const event = createEvent()
const user = { id: '1', email: 'user@example.com', name: 'User', password: 'hash' }
mockSuccessReadBody({ email: user.email })
authUtils.readUsers.mockResolvedValue([user])
authUtils.hashPassword.mockResolvedValue('new-hash')
authUtils.writeUsers.mockResolvedValue(true)
const response = await resetPasswordHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalled()
expect(authUtils.revokeRefreshSessionsForUser).toHaveBeenCalledWith('1', 'password_reset')
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.hashPassword.mockResolvedValue('new-hash')
authUtils.writeUsers.mockResolvedValue(true)
await resetPasswordHandler(event)
expect(authUtils.writeUsers).toHaveBeenCalled()
expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ')
})
it('ändert das Passwort nicht, 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' }])
authUtils.hashPassword.mockResolvedValue('new-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 aktivieren', async () => {
const event = createEvent()
mockSuccessReadBody({ email: 'user@example.com' })
authUtils.readUsers.mockResolvedValue([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
authUtils.hashPassword.mockResolvedValue('new-hash')
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.writeUsers).not.toHaveBeenCalled()
expect(passwordResetLog.writePasswordResetLog).toHaveBeenCalledWith(expect.objectContaining({
step: 'mail_send',
status: 'failed'
}))
})
})
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)
})
})
})