diff --git a/server/backup.test.js b/server/backup.test.js index b85133b..8236c83 100644 --- a/server/backup.test.js +++ b/server/backup.test.js @@ -9,7 +9,7 @@ const { putDoc } = require('./db'); const { runBackup, BACKUP_DIR } = require('./backup'); test('runBackup erzeugt eine nicht-leere DB-Datei', async () => { - putDoc('employees', ['Max'], '2026-07-07T10:00:00.000Z'); + putDoc(1, 'employees', ['Max'], '2026-07-07T10:00:00.000Z'); const dest = await runBackup('2026-07-07T10:00:00.000Z'); assert.ok(fs.existsSync(dest), 'Backup-Datei existiert'); assert.ok(fs.statSync(dest).size > 0, 'Backup ist nicht leer'); diff --git a/server/db.js b/server/db.js index 174516c..318d3d6 100644 --- a/server/db.js +++ b/server/db.js @@ -13,15 +13,18 @@ db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS documents ( - key TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + key TEXT NOT NULL, value TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, key) ); CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL, value TEXT NOT NULL, - replaced_at TEXT NOT NULL + replaced_at TEXT NOT NULL, + user_id INTEGER ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -53,26 +56,26 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); `); -function getDoc(key) { - const row = db.prepare('SELECT value, updated_at FROM documents WHERE key = ?').get(key); +function getDoc(userId, key) { + const row = db.prepare('SELECT value, updated_at FROM documents WHERE user_id = ? AND key = ?').get(userId, key); if (!row) return null; return { value: JSON.parse(row.value), updatedAt: row.updated_at }; } -// ponytail: history wächst unbegrenzt. Bei Single-User/kleinen Docs jahrelang egal. -// Pruning (z.B. > 500 Einträge pro key löschen) nachrüsten, falls es je wächst. -function putDoc(key, value, now) { +// ponytail: history wächst unbegrenzt. Bei kleinen Docs jahrelang egal. +// Pruning (z.B. > 500 Einträge pro user/key löschen) nachrüsten, falls es je wächst. +function putDoc(userId, key, value, now) { const json = JSON.stringify(value); const tx = db.transaction(() => { - const existing = db.prepare('SELECT value FROM documents WHERE key = ?').get(key); + const existing = db.prepare('SELECT value FROM documents WHERE user_id = ? AND key = ?').get(userId, key); if (existing) { - db.prepare('INSERT INTO history (key, value, replaced_at) VALUES (?, ?, ?)') - .run(key, existing.value, now); + db.prepare('INSERT INTO history (key, value, replaced_at, user_id) VALUES (?, ?, ?, ?)') + .run(key, existing.value, now, userId); } db.prepare(` - INSERT INTO documents (key, value, updated_at) VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `).run(key, json, now); + INSERT INTO documents (user_id, key, value, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `).run(userId, key, json, now); }); tx(); } diff --git a/server/db.test.js b/server/db.test.js index 9de9372..bc6c8d2 100644 --- a/server/db.test.js +++ b/server/db.test.js @@ -4,26 +4,34 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -// DATA_DIR auf Temp umbiegen, BEVOR db.js geladen wird process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-db-')); -const { getDoc, putDoc } = require('./db'); +const { getDoc, putDoc, db } = require('./db'); -test('putDoc/getDoc Roundtrip', () => { - putDoc('employees', ['Max', 'Anna'], '2026-07-07T10:00:00.000Z'); - const doc = getDoc('employees'); +const UID = 1; + +test('putDoc/getDoc Roundtrip (pro Nutzer)', () => { + putDoc(UID, 'employees', ['Max', 'Anna'], '2026-07-07T10:00:00.000Z'); + const doc = getDoc(UID, 'employees'); assert.deepStrictEqual(doc.value, ['Max', 'Anna']); assert.strictEqual(doc.updatedAt, '2026-07-07T10:00:00.000Z'); }); test('getDoc für unbekannten Key liefert null', () => { - assert.strictEqual(getDoc('gibtsnicht'), null); + assert.strictEqual(getDoc(UID, 'gibtsnicht'), null); }); -test('putDoc snapshottet den alten Wert in history', () => { - const { db } = require('./db'); - putDoc('duties', { v: 1 }, '2026-07-07T10:00:00.000Z'); - putDoc('duties', { v: 2 }, '2026-07-07T11:00:00.000Z'); - const rows = db.prepare("SELECT value FROM history WHERE key = 'duties' ORDER BY id").all(); +test('putDoc snapshottet den alten Wert in history (mit user_id)', () => { + putDoc(UID, 'duties', { v: 1 }, '2026-07-07T10:00:00.000Z'); + putDoc(UID, 'duties', { v: 2 }, '2026-07-07T11:00:00.000Z'); + const rows = db.prepare("SELECT value, user_id FROM history WHERE key='duties' AND user_id=? ORDER BY id").all(UID); assert.strictEqual(rows.length, 1); assert.deepStrictEqual(JSON.parse(rows[0].value), { v: 1 }); + assert.strictEqual(rows[0].user_id, UID); +}); + +test('Datentrennung: verschiedene user_id → disjunkte Daten', () => { + putDoc(10, 'employees', ['A'], '2026-07-07T10:00:00.000Z'); + putDoc(20, 'employees', ['B'], '2026-07-07T10:00:00.000Z'); + assert.deepStrictEqual(getDoc(10, 'employees').value, ['A']); + assert.deepStrictEqual(getDoc(20, 'employees').value, ['B']); }); diff --git a/server/index.js b/server/index.js index 0055396..a71765c 100644 --- a/server/index.js +++ b/server/index.js @@ -9,6 +9,7 @@ const { sendMagicLink } = require('./mailer'); const { normalizeEmail, hashToken, createLoginToken, consumeLoginToken, createSession, validateSession, deleteSession, SESSION_TTL_DAYS, + seedAdmin, migrateToMultiUser, } = require('./auth'); const app = express(); @@ -43,7 +44,7 @@ function authMiddleware(req, res, next) { const raw = req.cookies && req.cookies[SESSION_COOKIE]; const u = raw ? validateSession(raw) : null; if (!u) { clearSessionCookie(res); return res.status(401).json({ error: 'nicht angemeldet' }); } - req.user = u; + req.user = { id: u.userId, email: u.email, isAdmin: u.isAdmin }; next(); } function adminMiddleware(req, res, next) { @@ -118,25 +119,28 @@ app.post('/api/admin/login-link', authMiddleware, adminMiddleware, (req, res) => res.json({ url: `${baseUrl(req)}/auth?token=${raw}` }); }); -// ── Daten (global — wird in Epic 3.1 pro Nutzer + hinter Auth gestellt) ── -app.get('/api/state', (req, res) => { +// ── 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 }; for (const key of KEYS) { - const doc = getDoc(key); + const doc = getDoc(req.user.id, key); if (doc) { state[key] = doc.value; if (!state.updatedAt || doc.updatedAt > state.updatedAt) state.updatedAt = doc.updatedAt; } } res.json(state); }); -app.put('/api/state', (req, res) => { +app.put('/api/state', authMiddleware, (req, res) => { const body = req.body || {}; const now = new Date().toISOString(); - for (const key of KEYS) { if (body[key] !== undefined) putDoc(key, body[key], now); } + for (const key of KEYS) { if (body[key] !== undefined) putDoc(req.user.id, key, body[key], now); } res.json({ status: 'ok', updatedAt: now }); }); app.use(express.static(path.join(__dirname, '..'))); if (require.main === module) { + // Startup-Reihenfolge: Fail-Fast auf ADMIN_EMAIL → Seed → Migration → listen. + const adminId = seedAdmin(process.env.ADMIN_EMAIL); + migrateToMultiUser(adminId); const PORT = process.env.PORT || 3000; app.listen(PORT, '0.0.0.0', () => { console.log(`Dienstplan-Pro auf :${PORT}`); scheduleBackups(); }); } diff --git a/server/state.test.js b/server/state.test.js index b8f76f3..66773cb 100644 --- a/server/state.test.js +++ b/server/state.test.js @@ -6,37 +6,56 @@ const path = require('path'); process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dp-state-')); const app = require('./index'); +const { db } = require('./db'); +const { createSession } = require('./auth'); -async function req(port, method, body) { +function seedUser(email) { + return Number(db.prepare('INSERT INTO users (email,is_admin,created_at) VALUES (?,0,?)') + .run(email, new Date().toISOString()).lastInsertRowid); +} +async function withServer(fn) { + const s = app.listen(0); + try { return await fn(s.address().port); } finally { s.close(); } +} +async function state(port, method, cookie, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (cookie) opts.headers.Cookie = cookie; if (body) opts.body = JSON.stringify(body); const res = await fetch(`http://127.0.0.1:${port}/api/state`, opts); - return { status: res.status, body: await res.json() }; + let b = null; try { b = await res.json(); } catch { /* leer */ } + return { status: res.status, body: b }; } -test('leerer Server liefert leeren State', async () => { - const server = app.listen(0); - const { port } = server.address(); - try { - const { status, body } = await req(port, 'GET'); - assert.strictEqual(status, 200); - assert.deepStrictEqual(body.employees, []); - assert.deepStrictEqual(body.duties, {}); - assert.deepStrictEqual(body.vacation, {}); - assert.strictEqual(body.updatedAt, null); - } finally { server.close(); } +test('ohne Session → 401', async () => { + await withServer(async (port) => { + assert.strictEqual((await state(port, 'GET', null)).status, 401); + }); }); -test('PUT dann GET Roundtrip', async () => { - const server = app.listen(0); - const { port } = server.address(); - try { - const put = await req(port, 'PUT', { employees: ['Max'], duties: { Max: { '2026-07': [] } } }); - assert.strictEqual(put.status, 200); - assert.strictEqual(put.body.status, 'ok'); - const get = await req(port, 'GET'); - assert.deepStrictEqual(get.body.employees, ['Max']); - assert.deepStrictEqual(get.body.duties, { Max: { '2026-07': [] } }); - assert.strictEqual(typeof get.body.updatedAt, 'string'); - } finally { server.close(); } +test('eigener leerer State; PUT→GET Roundtrip', async () => { + const cookie = 'session=' + createSession(seedUser('a@x.de')); + await withServer(async (port) => { + let r = await state(port, 'GET', cookie); + assert.strictEqual(r.status, 200); + assert.deepStrictEqual(r.body.employees, []); + r = await state(port, 'PUT', cookie, { employees: ['Max'], duties: { Max: { '2026-07': [] } } }); + assert.strictEqual(r.status, 200); + r = await state(port, 'GET', cookie); + assert.deepStrictEqual(r.body.employees, ['Max']); + assert.deepStrictEqual(r.body.duties, { Max: { '2026-07': [] } }); + }); +}); + +test('Datentrennung + Anti-IDOR: zwei Nutzer disjunkt, Client-user_id wird ignoriert', async () => { + const a = seedUser('user-a@x.de'); + const b = seedUser('user-b@x.de'); + const ca = 'session=' + createSession(a); + const cb = 'session=' + createSession(b); + await withServer(async (port) => { + // A schreibt und schmuggelt fremde user_id in den Body → muss wirkungslos sein + await state(port, 'PUT', ca, { employees: ['A'], user_id: b }); + await state(port, 'PUT', cb, { employees: ['B'] }); + assert.deepStrictEqual((await state(port, 'GET', ca)).body.employees, ['A']); + assert.deepStrictEqual((await state(port, 'GET', cb)).body.employees, ['B'], 'B unberührt vom user_id-Schmuggel'); + }); });