487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|