feat(auth): Story 3.1 — /api/state pro Nutzer hinter Auth (Anti-IDOR), documents (user_id,key), Startup seed+migrate
This commit is contained in:
parent
0b25c7b493
commit
846ca4f83a
5 changed files with 91 additions and 57 deletions
|
|
@ -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');
|
||||
|
|
|
|||
31
server/db.js
31
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue