diff --git a/backend/src/auth/authMiddleware.ts b/backend/src/auth/authMiddleware.ts index dbb8aaf..69f549a 100644 --- a/backend/src/auth/authMiddleware.ts +++ b/backend/src/auth/authMiddleware.ts @@ -44,3 +44,20 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply, authS } return user; } + +/** + * Require library-admin role (Task H3: global library write guard). + * Library-admin is bound to the existing admin role: only admins may + * write (create/update/delete/import) the GLOBAL block library, while + * every authenticated user may read/export it. Own drawing-scoped + * blocks are unaffected (per-user ownership there). + */ +export function requireLibraryAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null { + const user = requireAuth(request, reply, authService); + if (!user) return null; + if (user.role !== 'admin') { + reply.code(403).send({ error: 'Library admin access required' }); + return null; + } + return user; +} diff --git a/backend/src/routes/globalBlocks.ts b/backend/src/routes/globalBlocks.ts index 720003c..62172ab 100644 --- a/backend/src/routes/globalBlocks.ts +++ b/backend/src/routes/globalBlocks.ts @@ -3,7 +3,7 @@ */ import type { FastifyInstance } from 'fastify'; import type { DatabaseInterface } from '../database/DatabaseInterface.js'; -import { requireAuth } from '../auth/authMiddleware.js'; +import { requireAuth, requireLibraryAdmin } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; import { validateName, validateIdParam } from '../utils/validation.js'; import JSZip from 'jszip'; @@ -36,7 +36,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // Create a new folder fastify.post('/api/global-folders', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const body = request.body as { name?: string; parent_id?: string | null }; const nameErr = validateName(body.name, 'name'); if (nameErr) return reply.code(400).send({ error: nameErr }); @@ -49,7 +49,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // Rename / move a folder fastify.patch('/api/global-folders/:id', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); @@ -71,7 +71,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // Delete a folder (cascades to children, blocks get folder_id = NULL) fastify.delete('/api/global-folders/:id', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); @@ -107,7 +107,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // Create a new global block fastify.post('/api/global-blocks', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string }; const nameErr = validateName(body.name, 'name'); if (nameErr) return reply.code(400).send({ error: nameErr }); @@ -131,7 +131,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // Update a global block (rename, move, update data) fastify.patch('/api/global-blocks/:id', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); @@ -160,7 +160,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // Delete a global block fastify.delete('/api/global-blocks/:id', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); @@ -192,7 +192,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten) fastify.post('/api/global-blocks/import', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const pkg = request.body as { format?: string; folders?: Array<{ id: string; name: string; parent_id: string | null }>; @@ -280,7 +280,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database // ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails) fastify.post('/api/global-blocks/import-zip', async (request, reply) => { - if (!requireAuth(request, reply, authService)) return; + if (!requireLibraryAdmin(request, reply, authService)) return; const body = request.body; if (!Buffer.isBuffer(body) || body.length === 0) { return reply.code(400).send({ error: 'ZIP body fehlt oder leer' }); diff --git a/backend/tests/libraryAdminGuard.test.ts b/backend/tests/libraryAdminGuard.test.ts new file mode 100644 index 0000000..c2bcead --- /dev/null +++ b/backend/tests/libraryAdminGuard.test.ts @@ -0,0 +1,160 @@ +/** + * Task H3 – Rollen: library-admin (global write) vs. user (read-only). + * + * requireLibraryAdmin schützt alle schreibenden Global-Library-Routen: + * POST/PATCH/DELETE auf folders+blocks, import, import-zip. Lesen + * (GET, export) bleibt für alle authentifizierten Nutzer offen. + * library-admin ist an die bestehende admin-Rolle angebunden. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { createServer } from '../src/server.js'; + +describe('Task H3: library-admin Guard', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let adminToken: string; + let planerToken: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + const adminReg = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: 'h3-admin@example.com', password: 'Password123!', name: 'H3 Admin', role: 'admin' }, + }); + if (adminReg.statusCode !== 201) console.error('ADMIN REG FAILED:', adminReg.statusCode, adminReg.body.slice(0, 200)); + adminToken = JSON.parse(adminReg.body).session?.token ?? ''; + + const planerReg = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: 'h3-planer@example.com', password: 'Password123!', name: 'H3 Planer', role: 'planer' }, + }); + if (planerReg.statusCode !== 201) console.error('PLANER REG FAILED:', planerReg.statusCode, planerReg.body.slice(0, 200)); + planerToken = JSON.parse(planerReg.body).session?.token ?? ''; + admin = { authorization: `Bearer ${adminToken}` }; + planer = { authorization: `Bearer ${planerToken}` }; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + // WICHTIG: Header-Objekte werden in beforeAll gebaut — der describe-Body + // läuft VOR den Hooks, Template-Literale würden undefined capturen. + let planer: Record; + let admin: Record; + + // ── Lesen bleibt offen (beide Rollen) ── + + it('planer kann globale Ordner LESEN (200)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/global-folders', headers: planer }); + expect(res.statusCode).toBe(200); + }); + + it('planer kann globale Blöcke LESEN (200)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: planer }); + expect(res.statusCode).toBe(200); + }); + + it('planer kann Library EXPORTIEREN (GET export, 200)', async () => { + const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export', headers: planer }); + expect(res.statusCode).toBe(200); + }); + + // ── Schreiben nur für library-admin (admin-Rolle) ── + + it('planer POST /api/global-folders → 403', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/global-folders', headers: planer, + payload: { name: 'Verboten', parent_id: null }, + }); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).error).toContain('Library'); + }); + + it('planer POST /api/global-blocks → 403', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/global-blocks', headers: planer, + payload: { name: 'Verboten', block_data: '[]' }, + }); + expect(res.statusCode).toBe(403); + }); + + it('planer PATCH /api/global-folders/:id → 403', async () => { + const res = await app.inject({ + method: 'PATCH', url: '/api/global-folders/irgendeine-id', headers: planer, + payload: { name: 'Hack' }, + }); + expect(res.statusCode).toBe(403); + }); + + it('planer PATCH /api/global-blocks/:id → 403', async () => { + const res = await app.inject({ + method: 'PATCH', url: '/api/global-blocks/irgendeine-id', headers: planer, + payload: { name: 'Hack' }, + }); + expect(res.statusCode).toBe(403); + }); + + it('planer DELETE /api/global-folders/:id → 403', async () => { + const res = await app.inject({ method: 'DELETE', url: '/api/global-folders/irgendeine-id', headers: planer }); + expect(res.statusCode).toBe(403); + }); + + it('planer DELETE /api/global-blocks/:id → 403', async () => { + const res = await app.inject({ method: 'DELETE', url: '/api/global-blocks/irgendeine-id', headers: planer }); + expect(res.statusCode).toBe(403); + }); + + it('planer POST /api/global-blocks/import → 403', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/global-blocks/import', headers: planer, + payload: { format: 'wcadlib', blocks: [] }, + }); + expect(res.statusCode).toBe(403); + }); + + it('planer POST /api/global-blocks/import-zip → 403', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/global-blocks/import-zip', headers: { ...planer, 'content-type': 'application/zip' }, + payload: Buffer.from('PK'), + }); + expect(res.statusCode).toBe(403); + }); + + // ── Admin (library-admin) darf schreiben ── + + it('admin POST /api/global-folders → 201 (write erlaubt)', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/global-folders', headers: admin, + payload: { name: 'Admin Set', parent_id: null }, + }); + expect(res.statusCode).toBe(201); + }); + + it('admin POST /api/global-blocks → 201 (write erlaubt)', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/global-blocks', headers: admin, + payload: { name: 'Admin Block', block_data: '[]', folder_id: null }, + }); + expect(res.statusCode).toBe(201); + }); + + // ── Unauthentifiziert bleibt 401 ── + + it('ohne Token POST /api/global-folders → 401 (nicht 403)', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/global-folders', + payload: { name: 'Anon', parent_id: null }, + }); + expect(res.statusCode).toBe(401); + }); +});