diff --git a/server/admin.test.js b/server/admin.test.js new file mode 100644 index 0000000..d3f8abf --- /dev/null +++ b/server/admin.test.js @@ -0,0 +1,66 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-admin-')); +const app = require('./index'); +const { db } = require('./db'); +const { createSession, validateSession } = require('./auth'); + +function seedUser(email, isAdmin = 0) { + return Number(db.prepare('INSERT INTO users (email,is_admin,created_at) VALUES (?,?,?)') + .run(email, isAdmin, new Date().toISOString()).lastInsertRowid); +} +async function withServer(fn) { const s = app.listen(0); try { return await fn(s.address().port); } finally { s.close(); } } +function api(port, method, p, cookie, body) { + const opts = { method, headers: {} }; + if (cookie) opts.headers.Cookie = cookie; + if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + return fetch(`http://127.0.0.1:${port}${p}`, opts); +} + +test('Nicht-Admin → 403; ohne Session → 401', async () => { + const cookie = 'session=' + createSession(seedUser('plain@x.de', 0)); + await withServer(async (port) => { + assert.strictEqual((await api(port, 'GET', '/api/admin/users', cookie)).status, 403); + assert.strictEqual((await api(port, 'GET', '/api/admin/users', null)).status, 401); + }); +}); + +test('Admin: freischalten (normalisiert, idempotent) + auflisten', async () => { + const cookie = 'session=' + createSession(seedUser('boss@x.de', 1)); + await withServer(async (port) => { + assert.strictEqual((await api(port, 'POST', '/api/admin/users', cookie, { email: ' New@X.DE ' })).status, 200); + assert.strictEqual((await api(port, 'POST', '/api/admin/users', cookie, { email: 'new@x.de' })).status, 200); // idempotent + assert.strictEqual(db.prepare("SELECT COUNT(*) c FROM users WHERE email='new@x.de'").get().c, 1); + const list = await (await api(port, 'GET', '/api/admin/users', cookie)).json(); + assert.ok(list.users.some(u => u.email === 'new@x.de' && u.isAdmin === false)); + }); +}); + +test('Admin: Nutzer entfernen räumt dessen Sessions (CASCADE)', async () => { + const adminCookie = 'session=' + createSession(seedUser('boss2@x.de', 1)); + const victimId = seedUser('victim@x.de', 0); + const victimRaw = createSession(victimId); + await withServer(async (port) => { + assert.ok(validateSession(victimRaw), 'Opfer hat gültige Session vor Löschung'); + assert.strictEqual((await api(port, 'DELETE', `/api/admin/users/${victimId}`, adminCookie)).status, 200); + assert.strictEqual(db.prepare('SELECT COUNT(*) c FROM users WHERE id=?').get(victimId).c, 0); + assert.strictEqual(validateSession(victimRaw), null, 'Session per CASCADE weg'); + }); +}); + +test('Last-Admin-Schutz: sich selbst / letzten Admin entfernen → 400', async () => { + const adminId = seedUser('solo@x.de', 1); + const adminCookie = 'session=' + createSession(adminId); + await withServer(async (port) => { + // sich selbst (zugleich letzter Admin) + assert.strictEqual((await api(port, 'DELETE', `/api/admin/users/${adminId}`, adminCookie)).status, 400); + // ein zweiter Admin, dann Löschen des zweiten ist ok, aber der letzte bleibt geschützt + const second = seedUser('second-admin@x.de', 1); + assert.strictEqual((await api(port, 'DELETE', `/api/admin/users/${second}`, adminCookie)).status, 200); + assert.strictEqual((await api(port, 'DELETE', `/api/admin/users/${adminId}`, adminCookie)).status, 400); + }); +}); diff --git a/server/index.js b/server/index.js index a71765c..6c35c86 100644 --- a/server/index.js +++ b/server/index.js @@ -119,6 +119,36 @@ app.post('/api/admin/login-link', authMiddleware, adminMiddleware, (req, res) => res.json({ url: `${baseUrl(req)}/auth?token=${raw}` }); }); +// ── Admin: Nutzerverwaltung (Allowlist) ────────────────────────────── +app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { + const rows = db.prepare('SELECT id, email, is_admin FROM users ORDER BY email').all(); + res.json({ users: rows.map(u => ({ id: u.id, email: u.email, isAdmin: !!u.is_admin })) }); +}); + +app.post('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { + const email = normalizeEmail((req.body && req.body.email) || ''); + if (!email || !email.includes('@')) return res.status(400).json({ error: 'Ungültige E-Mail' }); + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (!existing) { + db.prepare('INSERT INTO users (email, is_admin, created_at) VALUES (?, 0, ?)').run(email, new Date().toISOString()); + audit('admin_add', req.user.id, ipHashOf(req)); + } + res.json({ ok: true }); +}); + +app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, (req, res) => { + const id = parseInt(req.params.id, 10); + const target = db.prepare('SELECT id, is_admin FROM users WHERE id = ?').get(id); + if (!target) return res.status(404).json({ error: 'Nutzer nicht gefunden' }); + if (target.id === req.user.id) return res.status(400).json({ error: 'Du kannst dich nicht selbst entfernen.' }); + if (target.is_admin && db.prepare('SELECT COUNT(*) c FROM users WHERE is_admin = 1').get().c <= 1) { + return res.status(400).json({ error: 'Der letzte Admin kann nicht entfernt werden.' }); + } + db.prepare('DELETE FROM users WHERE id = ?').run(id); // CASCADE räumt Sessions/Tokens + audit('admin_remove', req.user.id, ipHashOf(req)); + res.json({ ok: true }); +}); + // ── Daten pro Nutzer (hinter Auth; user_id NUR aus der Session, nie aus dem Client) ── app.get('/api/state', authMiddleware, (req, res) => { const state = { ...EMPTY, updatedAt: null };