feat(auth): Stories 3.2/3.3 — Admin-Nutzerverwaltung (add/list/remove) + Last-Admin-Guard + CASCADE
This commit is contained in:
parent
846ca4f83a
commit
facbb48bbf
2 changed files with 96 additions and 0 deletions
66
server/admin.test.js
Normal file
66
server/admin.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue