From 19d7aeefb06c42b40a91c01cb5f85cb834567a8e Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 21 May 2026 08:03:59 +0200 Subject: [PATCH] test: expand endpoint coverage and harden deploy gate --- .gitea/workflows/code-analysis.yml | 4 +- server/api/galerie/[id].delete.js | 2 +- tests/cms-files-endpoints.spec.ts | 68 ++++++- tests/config-profile-endpoints.spec.ts | 237 ++++++++++++++++++++++ tests/galerie-endpoints.spec.ts | 67 ++++++ tests/members-endpoints.spec.ts | 81 ++++++++ tests/news-endpoints.spec.ts | 160 +++++++++++++++ tests/setup.ts | 11 +- tests/spielplan-public-endpoints.spec.ts | 248 +++++++++++++++++++++++ tests/termine-manage-endpoints.spec.ts | 149 ++++++++++++++ 10 files changed, 1013 insertions(+), 14 deletions(-) create mode 100644 tests/config-profile-endpoints.spec.ts create mode 100644 tests/news-endpoints.spec.ts create mode 100644 tests/spielplan-public-endpoints.spec.ts create mode 100644 tests/termine-manage-endpoints.spec.ts diff --git a/.gitea/workflows/code-analysis.yml b/.gitea/workflows/code-analysis.yml index 057419d..076e763 100644 --- a/.gitea/workflows/code-analysis.yml +++ b/.gitea/workflows/code-analysis.yml @@ -100,7 +100,7 @@ jobs: deploy-production: needs: analyze runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Prepare SSH run: | @@ -131,7 +131,7 @@ jobs: deploy-test: needs: analyze runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + if: success() && github.event_name == 'push' && github.ref == 'refs/heads/dev' steps: - name: Prepare SSH run: | diff --git a/server/api/galerie/[id].delete.js b/server/api/galerie/[id].delete.js index d9772b2..dc05e2c 100644 --- a/server/api/galerie/[id].delete.js +++ b/server/api/galerie/[id].delete.js @@ -1,6 +1,6 @@ import fs from 'fs/promises' import path from 'path' -import { getUserFromToken, verifyToken } from '../../utils/auth.js' +import { getUserFromToken, hasAnyRole, verifyToken } from '../../utils/auth.js' // Handle both dev and production paths // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal diff --git a/tests/cms-files-endpoints.spec.ts b/tests/cms-files-endpoints.spec.ts index a71e591..67091f4 100644 --- a/tests/cms-files-endpoints.spec.ts +++ b/tests/cms-files-endpoints.spec.ts @@ -2,15 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createEvent, mockSuccessReadBody } from './setup' import fs from 'fs/promises' -vi.mock('../server/utils/auth.js', () => ({ - getUserFromToken: vi.fn(), - 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('multer', () => { const single = vi.fn((field) => (req, _res, cb) => { if (req.__mockMulterError) { @@ -31,6 +22,18 @@ vi.mock('multer', () => { diskStorage } }) +vi.mock('../server/utils/contact-requests.js', () => ({ + readContactRequests: vi.fn() +})) + +vi.mock('../server/utils/auth.js', () => ({ + getUserFromToken: vi.fn(), + 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('child_process', () => ({ exec: vi.fn() @@ -74,8 +77,11 @@ vi.mock('../server/utils/upload-validation.js', () => ({ import saveCsvHandler from '../server/api/cms/save-csv.post.js' import uploadSpielplanHandler from '../server/api/cms/upload-spielplan-pdf.post.js' import satzungUploadHandler from '../server/api/cms/satzung-upload.post.js' +import contactRequestsHandler from '../server/api/cms/contact-requests.get.js' -const { getUserFromToken } = await import('../server/utils/auth.js') +const contactRequestUtils = await import('../server/utils/contact-requests.js') + +const { getUserFromToken, hasAnyRole } = await import('../server/utils/auth.js') describe('CMS File Endpoints', () => { beforeEach(() => { @@ -155,4 +161,46 @@ describe('CMS File Endpoints', () => { expect(fs.writeFile).toHaveBeenCalled() }) }) + + describe('GET /api/cms/contact-requests', () => { + it('gibt 403 wenn kein Token vorhanden', async () => { + const event = createEvent() + + await expect(contactRequestsHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('gibt 403 bei unzureichender Rolle', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + getUserFromToken.mockResolvedValue({ id: '1', roles: ['mitglied'] }) + hasAnyRole.mockReturnValue(false) + + await expect(contactRequestsHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('liefert Kontaktanfragen für Admin', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + getUserFromToken.mockResolvedValue({ id: '1', roles: ['admin'] }) + hasAnyRole.mockReturnValue(true) + contactRequestUtils.readContactRequests.mockResolvedValue([ + { name: 'Test User', email: 'test@example.com', message: 'Hallo' } + ]) + + const result = await contactRequestsHandler(event) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Test User') + }) + + it('liefert Kontaktanfragen für Trainer', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + getUserFromToken.mockResolvedValue({ id: '2', roles: ['trainer'] }) + hasAnyRole.mockReturnValue(true) + contactRequestUtils.readContactRequests.mockResolvedValue([]) + + const result = await contactRequestsHandler(event) + + expect(Array.isArray(result)).toBe(true) + }) + }) }) diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts new file mode 100644 index 0000000..702006a --- /dev/null +++ b/tests/config-profile-endpoints.spec.ts @@ -0,0 +1,237 @@ +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(), + 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' + +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') + }) + }) + + 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() + }) + + 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: 'falsch', newPassword: 'neuesPasswort123' + }) + + 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: 'richtig', newPassword: 'neuesPasswort123' + }) + + const result = await profilePutHandler(event) + + expect(result.success).toBe(true) + expect(authUtils.hashPassword).toHaveBeenCalledWith('neuesPasswort123') + }) + }) +}) diff --git a/tests/galerie-endpoints.spec.ts b/tests/galerie-endpoints.spec.ts index bb0c8ad..0d094f9 100644 --- a/tests/galerie-endpoints.spec.ts +++ b/tests/galerie-endpoints.spec.ts @@ -51,6 +51,7 @@ const authUtils = await import('../server/utils/auth.js') import uploadHandler from '../server/api/galerie/upload.post.js' import listHandler from '../server/api/galerie/list.get.js' import imageHandler from '../server/api/galerie/[id].get.js' +import deleteImageHandler from '../server/api/galerie/[id].delete.js' describe('Galerie API Endpoints', () => { beforeEach(() => { @@ -185,5 +186,71 @@ describe('Galerie API Endpoints', () => { expect(response).toBeInstanceOf(Buffer) }) }) + + describe('DELETE /api/galerie/[id]', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent({ method: 'DELETE' }) + event.context.params = { id: '1' } + + await expect(deleteImageHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) + + it('verlangt Admin- oder Vorstand-Rolle', async () => { + const event = createEvent({ method: 'DELETE', cookies: { auth_token: 'token' } }) + event.context.params = { id: '1' } + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', roles: ['mitglied'] }) + authUtils.hasAnyRole.mockReturnValue(false) + + await expect(deleteImageHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('gibt 400 wenn keine Bild-ID übergeben', async () => { + const event = createEvent({ method: 'DELETE', cookies: { auth_token: 'token' } }) + // kein context.params.id + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', roles: ['admin'] }) + authUtils.hasAnyRole.mockReturnValue(true) + + await expect(deleteImageHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('gibt 404 wenn Bild nicht in Metadaten gefunden', async () => { + const event = createEvent({ method: 'DELETE', cookies: { auth_token: 'token' } }) + event.context.params = { id: 'unbekannt' } + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', roles: ['admin'] }) + authUtils.hasAnyRole.mockReturnValue(true) + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify([ + { id: 'anderes-bild', filename: 'other.jpg', previewFilename: 'preview_other.jpg' } + ])) + + await expect(deleteImageHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('löscht Bild und aktualisiert Metadaten', async () => { + const event = createEvent({ method: 'DELETE', cookies: { auth_token: 'token' } }) + event.context.params = { id: 'img-1' } + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', roles: ['admin'] }) + authUtils.hasAnyRole.mockReturnValue(true) + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify([ + { id: 'img-1', filename: 'bild.jpg', previewFilename: 'preview_bild.jpg' }, + { id: 'img-2', filename: 'bild2.jpg', previewFilename: 'preview_bild2.jpg' } + ])) + + const result = await deleteImageHandler(event) + + expect(result.success).toBe(true) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('img-2'), + expect.any(String) + ) + // img-1 darf nicht mehr in den Metadaten sein + const writtenData = JSON.parse((fs.writeFile as any).mock.calls[0][1]) + expect(writtenData.find((img: any) => img.id === 'img-1')).toBeUndefined() + }) + }) }) diff --git a/tests/members-endpoints.spec.ts b/tests/members-endpoints.spec.ts index 5fb5f59..8e69eeb 100644 --- a/tests/members-endpoints.spec.ts +++ b/tests/members-endpoints.spec.ts @@ -49,6 +49,8 @@ import membersGetHandler from '../server/api/members.get.js' import membersPostHandler from '../server/api/members.post.js' import membersDeleteHandler from '../server/api/members.delete.js' import membersBulkHandler from '../server/api/members/bulk.post.js' +import membersBulkHandler from '../server/api/members/bulk.post.js' +import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js' describe('Members API Endpoints', () => { beforeEach(() => { @@ -226,4 +228,83 @@ describe('Members API Endpoints', () => { expect(memberUtils.writeMembers).toHaveBeenCalled() }) }) + + describe('POST /api/members/toggle-mannschaftsspieler', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + mockSuccessReadBody({ memberId: '1' }) + + await expect(toggleMannschaftsspielerHandler(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({ memberId: '2' }) + + await expect(toggleMannschaftsspielerHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('gibt 400 wenn memberId fehlt', 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({}) + + await expect(toggleMannschaftsspielerHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('gibt 404 wenn Mitglied nicht gefunden', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserById.mockResolvedValue({ id: '1', roles: ['admin'] }) + authUtils.hasAnyRole.mockReturnValue(true) + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([]) + mockSuccessReadBody({ memberId: 'unbekannt' }) + + await expect(toggleMannschaftsspielerHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('toggled isMannschaftsspieler für eingeloggten User', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserById.mockResolvedValue({ id: '1', roles: ['admin'] }) + authUtils.hasAnyRole.mockReturnValue(true) + authUtils.readUsers.mockResolvedValue([ + { id: 'user-5', name: 'Max Muster', isMannschaftsspieler: false, roles: ['mitglied'] } + ]) + authUtils.writeUsers.mockResolvedValue(undefined) + memberUtils.readMembers.mockResolvedValue([]) + mockSuccessReadBody({ memberId: 'user-5' }) + + const result = await toggleMannschaftsspielerHandler(event) + + expect(result.success).toBe(true) + expect(result.isMannschaftsspieler).toBe(true) + expect(authUtils.writeUsers).toHaveBeenCalled() + }) + + it('toggled isMannschaftsspieler für manuelles Mitglied', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserById.mockResolvedValue({ id: '1', roles: ['admin'] }) + authUtils.hasAnyRole.mockReturnValue(true) + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([ + { id: 'manual-1', firstName: 'Paul', lastName: 'Team', isMannschaftsspieler: true } + ]) + memberUtils.writeMembers.mockResolvedValue(undefined) + mockSuccessReadBody({ memberId: 'manual-1' }) + + const result = await toggleMannschaftsspielerHandler(event) + + expect(result.success).toBe(true) + expect(result.isMannschaftsspieler).toBe(false) + expect(memberUtils.writeMembers).toHaveBeenCalled() + }) + }) }) diff --git a/tests/news-endpoints.spec.ts b/tests/news-endpoints.spec.ts new file mode 100644 index 0000000..67216e0 --- /dev/null +++ b/tests/news-endpoints.spec.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createEvent, mockSuccessReadBody } from './setup' + +vi.mock('../server/utils/auth.js', () => ({ + verifyToken: vi.fn(), + getUserById: vi.fn(), + 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/news.js', () => ({ + readNews: vi.fn(), + saveNews: vi.fn(), + deleteNews: vi.fn() +})) + +const authUtils = await import('../server/utils/auth.js') +const newsUtils = await import('../server/utils/news.js') + +import newsGetHandler from '../server/api/news.get.js' +import newsPostHandler from '../server/api/news.post.js' +import newsDeleteHandler from '../server/api/news.delete.js' + +describe('News API Endpoints', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const adminEvent = () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserById.mockResolvedValue({ id: '1', roles: ['admin'], name: 'Admin' }) + authUtils.hasAnyRole.mockReturnValue(true) + return event + } + + describe('GET /api/news', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + await expect(newsGetHandler(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(newsGetHandler(event)).rejects.toMatchObject({ statusCode: 401 }) + }) + + it('liefert News nach Datum sortiert (neueste zuerst)', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + newsUtils.readNews.mockResolvedValue([ + { id: 'a', title: 'Alt', created: '2024-01-01' }, + { id: 'b', title: 'Neu', created: '2025-06-01' } + ]) + + const result = await newsGetHandler(event) + + expect(result.success).toBe(true) + expect(result.news[0].id).toBe('b') + expect(result.news[1].id).toBe('a') + }) + }) + + describe('POST /api/news', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + mockSuccessReadBody({}) + await expect(newsPostHandler(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: 'T', content: 'C' }) + await expect(newsPostHandler(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: 'T', content: 'C' }) + await expect(newsPostHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('validiert Pflichtfelder – kein Titel', async () => { + const event = adminEvent() + mockSuccessReadBody({ content: 'Nur Inhalt ohne Titel' }) + await expect(newsPostHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('validiert Pflichtfelder – kein Inhalt', async () => { + const event = adminEvent() + mockSuccessReadBody({ title: 'Nur Titel' }) + await expect(newsPostHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('speichert News erfolgreich', async () => { + const event = adminEvent() + newsUtils.saveNews.mockResolvedValue(undefined) + mockSuccessReadBody({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true }) + + const result = await newsPostHandler(event) + + expect(result.success).toBe(true) + expect(newsUtils.saveNews).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true }) + ) + }) + + it('setzt autor auf den angemeldeten Benutzer', async () => { + const event = adminEvent() + newsUtils.saveNews.mockResolvedValue(undefined) + mockSuccessReadBody({ title: 'Titel', content: 'Inhalt' }) + + await newsPostHandler(event) + + expect(newsUtils.saveNews).toHaveBeenCalledWith( + expect.objectContaining({ author: 'Admin' }) + ) + }) + }) + + describe('DELETE /api/news', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + await expect(newsDeleteHandler(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) + await expect(newsDeleteHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('validiert fehlende News-ID', async () => { + const event = adminEvent() + // query ist leer → kein id + await expect(newsDeleteHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('löscht News erfolgreich', async () => { + const event = adminEvent() + event.__query = { id: 'abc-123' } + newsUtils.deleteNews.mockResolvedValue(undefined) + + const result = await newsDeleteHandler(event) + + expect(result.success).toBe(true) + expect(newsUtils.deleteNews).toHaveBeenCalledWith('abc-123') + }) + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts index 67de0b5..4da00ba 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,4 +1,4 @@ -import { vi } from 'vitest' +import { vi, beforeAll, afterAll, beforeEach } from 'vitest' type CookieStore = Record }> @@ -129,6 +129,15 @@ export const mockSuccessReadBody = (payload: any) => { (global.readBody as any).mockResolvedValue(payload) } +beforeAll(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterAll(() => { + vi.restoreAllMocks() +}) + beforeEach(() => { resetEventMocks() }) diff --git a/tests/spielplan-public-endpoints.spec.ts b/tests/spielplan-public-endpoints.spec.ts new file mode 100644 index 0000000..d620438 --- /dev/null +++ b/tests/spielplan-public-endpoints.spec.ts @@ -0,0 +1,248 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createEvent } from './setup' +import fs from 'fs/promises' + +vi.mock('../server/utils/spielplan-data.js', () => ({ + listSpielplanSeasons: vi.fn().mockResolvedValue(['25--26']), + readSpielplanData: vi.fn(), + validateSeasonSlug: vi.fn().mockReturnValue(true), + getCurrentSeasonSlug: vi.fn().mockReturnValue('25--26') +})) + +vi.mock('../server/utils/logger.js', () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn() +})) + +vi.mock('../server/utils/auth.js', () => ({ + verifyToken: vi.fn(), + getUserFromToken: vi.fn(), + readUsers: vi.fn().mockResolvedValue([]), + migrateUserRoles: vi.fn(user => user) +})) + +vi.mock('../server/utils/members.js', () => ({ + readMembers: vi.fn().mockResolvedValue([]), + normalizeDate: vi.fn(v => v) +})) + +const spielplanData = await import('../server/utils/spielplan-data.js') +const authUtils = await import('../server/utils/auth.js') +const memberUtils = await import('../server/utils/members.js') + +import spielplanHandler from '../server/api/spielplan.get.js' +import mannschaftenHandler from '../server/api/mannschaften.get.js' +import vereinsmeisterschaftenHandler from '../server/api/vereinsmeisterschaften.get.js' +import birthdaysHandler from '../server/api/birthdays.get.js' + +describe('Spielplan, Mannschaften & öffentliche Endpoints', () => { + beforeEach(() => { + vi.clearAllMocks() + spielplanData.validateSeasonSlug.mockReturnValue(true) + spielplanData.getCurrentSeasonSlug.mockReturnValue('25--26') + spielplanData.listSpielplanSeasons.mockResolvedValue(['25--26']) + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([]) + vi.spyOn(fs, 'readFile').mockResolvedValue('csv,data\nrow1,row2') + vi.spyOn(fs, 'access').mockResolvedValue(undefined) + }) + + describe('GET /api/spielplan', () => { + it('gibt Fehler bei ungültigem Saison-Slug zurück', async () => { + const event = createEvent({ query: { season: 'ungueltig' } }) + spielplanData.validateSeasonSlug.mockReturnValue(false) + + const result = await spielplanHandler(event) + + expect(result.success).toBe(false) + }) + + it('gibt Fehler zurück wenn Spielplan leer ist', async () => { + const event = createEvent() + spielplanData.readSpielplanData.mockResolvedValue({ data: [], headers: [] }) + + const result = await spielplanHandler(event) + + expect(result.success).toBe(false) + expect(result.data).toHaveLength(0) + }) + + it('liefert Spielplandaten mit Saisons', async () => { + const event = createEvent() + spielplanData.readSpielplanData.mockResolvedValue({ + data: [['Harheimer TC 1', 'Gegner', '3:1']], + headers: ['Heimteam', 'Gastteam', 'Ergebnis'], + source: 'file', + filePath: '/some/path', + season: '25--26' + }) + + const result = await spielplanHandler(event) + + expect(result.success).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.seasons).toContain('25--26') + }) + + it('liefert Spielplandaten für angeforderte Saison', async () => { + const event = createEvent({ query: { season: '24--25' } }) + spielplanData.readSpielplanData.mockResolvedValue({ + data: [['Team A', 'Team B', '2:3']], + headers: ['Heim', 'Gast', 'Ergebnis'], + source: 'file', + filePath: '/path', + season: '24--25' + }) + + const result = await spielplanHandler(event) + + expect(result.success).toBe(true) + expect(result.season).toBe('24--25') + }) + }) + + describe('GET /api/mannschaften', () => { + it('lehnt ungültigen Saison-Slug ab', async () => { + const event = createEvent({ query: { season: 'invalid' } }) + spielplanData.validateSeasonSlug.mockReturnValue(false) + + await expect(mannschaftenHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('gibt 404 wenn keine Mannschaftsdatei gefunden', async () => { + const event = createEvent() + vi.spyOn(fs, 'access').mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ) + + await expect(mannschaftenHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('liefert CSV-Inhalt der Mannschaftsdaten', async () => { + const event = createEvent() + vi.spyOn(fs, 'access').mockResolvedValue(undefined) + vi.spyOn(fs, 'readFile').mockResolvedValue('Name,Liga\nHarheimer TC 1,Kreisliga') + + const result = await mannschaftenHandler(event) + + expect(result).toContain('Harheimer TC 1') + expect(result).toContain('Kreisliga') + }) + + it('liefert CSV für angeforderte Saison', async () => { + const event = createEvent({ query: { season: '24--25' } }) + vi.spyOn(fs, 'access').mockResolvedValue(undefined) + vi.spyOn(fs, 'readFile').mockResolvedValue('Name,Liga\nHarheimer TC 2,Bezirksliga') + + const result = await mannschaftenHandler(event) + + expect(result).toContain('Harheimer TC 2') + }) + }) + + describe('GET /api/vereinsmeisterschaften', () => { + it('gibt 404 wenn Datei nicht gefunden', async () => { + vi.spyOn(fs, 'access').mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ) + vi.spyOn(fs, 'readFile').mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ) + const event = createEvent() + + await expect(vereinsmeisterschaftenHandler(event)).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('liefert CSV-Inhalt der Vereinsmeisterschaften', async () => { + vi.spyOn(fs, 'access').mockResolvedValue(undefined) + vi.spyOn(fs, 'readFile').mockResolvedValue('Jahr,Sieger\n2024,Max Muster') + const event = createEvent() + + const result = await vereinsmeisterschaftenHandler(event) + + expect(result).toContain('Max Muster') + expect(result).toContain('2024') + }) + }) + + describe('GET /api/birthdays', () => { + it('gibt leere Liste zurück wenn keine Mitglieder', async () => { + const event = createEvent() + memberUtils.readMembers.mockResolvedValue([]) + authUtils.readUsers.mockResolvedValue([]) + + const result = await birthdaysHandler(event) + + expect(result.success).toBe(true) + expect(result.birthdays).toHaveLength(0) + }) + + it('zeigt kommende Geburtstage der nächsten 28 Tage', async () => { + const event = createEvent() + const inDays = 7 + const targetDate = new Date() + targetDate.setDate(targetDate.getDate() + inDays) + const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}` + + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Max', lastName: 'Muster', geburtsdatum, visibility: { showBirthday: true } } + ]) + authUtils.readUsers.mockResolvedValue([]) + + const result = await birthdaysHandler(event) + + expect(result.birthdays).toHaveLength(1) + expect(result.birthdays[0].name).toBe('Max Muster') + }) + + it('versteckt Geburtstage wenn showBirthday=false', async () => { + const event = createEvent() + const inDays = 7 + const targetDate = new Date() + targetDate.setDate(targetDate.getDate() + inDays) + const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}` + + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Privat', lastName: 'Person', geburtsdatum, visibility: { showBirthday: false } } + ]) + authUtils.readUsers.mockResolvedValue([]) + + const result = await birthdaysHandler(event) + + expect(result.birthdays).toHaveLength(0) + }) + + it('zeigt Geburtstage mit deaktivierter Sichtbarkeit für Vorstand', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + const inDays = 7 + const targetDate = new Date() + targetDate.setDate(targetDate.getDate() + inDays) + const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}` + + authUtils.verifyToken.mockReturnValue({ id: 'v1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: 'v1', roles: ['vorstand'], active: true }) + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Privat', lastName: 'Person', geburtsdatum, visibility: { showBirthday: false } } + ]) + authUtils.readUsers.mockResolvedValue([]) + + const result = await birthdaysHandler(event) + + // Vorstand sieht alle, auch private + expect(result.birthdays).toHaveLength(1) + }) + + it('ignoriert Mitglieder ohne Geburtsdatum', async () => { + const event = createEvent() + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Kein', lastName: 'Datum', geburtsdatum: null } + ]) + authUtils.readUsers.mockResolvedValue([]) + + const result = await birthdaysHandler(event) + + expect(result.birthdays).toHaveLength(0) + }) + }) +}) diff --git a/tests/termine-manage-endpoints.spec.ts b/tests/termine-manage-endpoints.spec.ts new file mode 100644 index 0000000..d9ab4d9 --- /dev/null +++ b/tests/termine-manage-endpoints.spec.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createEvent, mockSuccessReadBody } from './setup' + +vi.mock('../server/utils/auth.js', () => ({ + verifyToken: vi.fn(), + getUserById: vi.fn(), + 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/termine.js', () => ({ + readTermine: vi.fn(), + saveTermin: vi.fn(), + deleteTermin: vi.fn() +})) + +const authUtils = await import('../server/utils/auth.js') +const termineUtils = await import('../server/utils/termine.js') + +import termineGetHandler from '../server/api/termine-manage.get.js' +import terminePostHandler from '../server/api/termine-manage.post.js' +import termineDeleteHandler from '../server/api/termine-manage.delete.js' + +describe('Termine-Manage API Endpoints', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const adminEvent = () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + authUtils.getUserById.mockResolvedValue({ id: '1', roles: ['admin'] }) + authUtils.hasAnyRole.mockReturnValue(true) + return event + } + + describe('GET /api/termine-manage', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + await expect(termineGetHandler(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(termineGetHandler(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) + await expect(termineGetHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('liefert Terminliste', async () => { + const event = adminEvent() + termineUtils.readTermine.mockResolvedValue([ + { datum: '2025-06-01', titel: 'Hauptversammlung', kategorie: 'Verein' } + ]) + + const result = await termineGetHandler(event) + + expect(result.success).toBe(true) + expect(result.termine).toHaveLength(1) + expect(result.termine[0].titel).toBe('Hauptversammlung') + }) + }) + + describe('POST /api/termine-manage', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + mockSuccessReadBody({}) + await expect(terminePostHandler(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({ datum: '2025-06-01', titel: 'Test' }) + await expect(terminePostHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('validiert Pflichtfelder – kein Titel', async () => { + const event = adminEvent() + mockSuccessReadBody({ datum: '2025-06-01' }) + await expect(terminePostHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('validiert Pflichtfelder – kein Datum', async () => { + const event = adminEvent() + mockSuccessReadBody({ titel: 'Training' }) + await expect(terminePostHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('speichert Termin erfolgreich', async () => { + const event = adminEvent() + termineUtils.saveTermin.mockResolvedValue(undefined) + mockSuccessReadBody({ datum: '2025-06-01', titel: 'Hauptversammlung', kategorie: 'Verein', uhrzeit: '19:00' }) + + const result = await terminePostHandler(event) + + expect(result.success).toBe(true) + expect(termineUtils.saveTermin).toHaveBeenCalledWith( + expect.objectContaining({ datum: '2025-06-01', titel: 'Hauptversammlung' }) + ) + }) + }) + + describe('DELETE /api/termine-manage', () => { + it('verlangt Authentifizierung', async () => { + const event = createEvent() + await expect(termineDeleteHandler(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) + await expect(termineDeleteHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('validiert Pflichtfelder – datum und titel fehlen', async () => { + const event = adminEvent() + // __query ist leer → keine datum/titel + await expect(termineDeleteHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('löscht Termin erfolgreich', async () => { + const event = adminEvent() + event.__query = { datum: '2025-06-01', titel: 'Hauptversammlung', kategorie: 'Verein' } + termineUtils.deleteTermin.mockResolvedValue(undefined) + + const result = await termineDeleteHandler(event) + + expect(result.success).toBe(true) + expect(termineUtils.deleteTermin).toHaveBeenCalledWith( + expect.objectContaining({ datum: '2025-06-01', titel: 'Hauptversammlung' }) + ) + }) + }) +})