Paßwort vergessen modernisiert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-06-09 10:31:32 +02:00
parent a98def915e
commit 300dce9835
7 changed files with 389 additions and 80 deletions

View File

@@ -1,6 +1,7 @@
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 {
@@ -74,6 +75,7 @@ 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'
@@ -81,12 +83,15 @@ 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()
})
@@ -300,7 +305,7 @@ describe('Auth API Endpoints', () => {
})
describe('POST /api/auth/reset-password', () => {
it('prüft Pflichtfelder', async () => {
it('prüft Pflichtfelder ohne öffentliche Fehlermeldung', async () => {
const event = createEvent()
mockSuccessReadBody({})
@@ -308,18 +313,27 @@ describe('Auth API Endpoints', () => {
expect(response.success).toBe(true)
})
it('aktualisiert Passwort bei vorhandenem Benutzer', async () => {
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: 'hash' }
const user = { id: '1', email: 'user@example.com', name: 'User', password: 'old-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(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',
@@ -332,7 +346,6 @@ describe('Auth API Endpoints', () => {
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)
@@ -341,11 +354,10 @@ describe('Auth API Endpoints', () => {
expect(passwordResetLog.normalizeResetEmail).toHaveBeenCalledWith(' User@Example.com ')
})
it('ändert das Passwort nicht, wenn SMTP nicht konfiguriert ist', async () => {
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' }])
authUtils.hashPassword.mockResolvedValue('new-hash')
delete process.env.SMTP_USER
delete process.env.SMTP_PASS
@@ -360,11 +372,12 @@ describe('Auth API Endpoints', () => {
}))
})
it('protokolliert einen Mailfehler ohne das Passwort zu aktivieren', async () => {
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([{ id: '1', email: 'user@example.com', name: 'User', password: 'hash' }])
authUtils.hashPassword.mockResolvedValue('new-hash')
authUtils.readUsers.mockResolvedValue([user])
authUtils.writeUsers.mockResolvedValue(true)
nodemailer.default.createTransport.mockReturnValueOnce({
sendMail: vi.fn().mockRejectedValue(Object.assign(new Error('SMTP fehlgeschlagen'), { code: 'EAUTH' }))
})
@@ -372,7 +385,8 @@ describe('Auth API Endpoints', () => {
const response = await resetPasswordHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).not.toHaveBeenCalled()
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'
@@ -380,6 +394,61 @@ describe('Auth API Endpoints', () => {
})
})
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()