diff --git a/package.json b/package.json index 9cfba09..a69ef89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.20.42", + "version": "0.20.43", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", @@ -22,9 +22,10 @@ "test:modal-utils": "node --loader ./test-browser-loader.mjs test-modal-utils.js", "test:reminders": "node --experimental-sqlite test-reminders.js", "test:api": "node test-api.js", + "test:setup": "node test-setup.js", "test:ics-parser": "node test-ics-parser.js", "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub" + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/server/auth.js b/server/auth.js index 5ecfca9..ecb372c 100644 --- a/server/auth.js +++ b/server/auth.js @@ -153,6 +153,8 @@ function requireAdmin(req, res, next) { // Routen // -------------------------------------------------------- +const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF2D55']; + /** * POST /api/v1/auth/login * Body: { username: string, password: string } @@ -233,6 +235,56 @@ router.post('/logout', requireAuth, csrfMiddleware, (req, res) => { }); }); +/** + * POST /api/v1/auth/setup + * First-run bootstrap: creates the first admin when no users exist. + * Returns 403 if any user already exists. + * Body: { username: string, display_name: string, password: string } + * Response: { user: { id, username, display_name, avatar_color, role } } + */ +router.post('/setup', loginLimiter, async (req, res) => { + try { + const { count } = db.get().prepare('SELECT COUNT(*) as count FROM users').get(); + if (count > 0) { + return res.status(403).json({ error: 'Setup bereits abgeschlossen.', code: 403 }); + } + + const username = (req.body.username || '').trim(); + const display_name = (req.body.display_name || '').trim(); + const { password } = req.body; + + if (!username || !display_name || !password) { + return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 }); + } + if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) { + return res.status(400).json({ error: 'Benutzername muss 3-64 Zeichen lang sein und darf nur Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche enthalten.', code: 400 }); + } + if (display_name.length > 128) { + return res.status(400).json({ error: 'Anzeigename darf maximal 128 Zeichen lang sein.', code: 400 }); + } + if (password.length < 8) { + return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 }); + } + + const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)]; + const hash = await bcrypt.hash(password, 12); + + const result = db.get() + .prepare('INSERT INTO users (username, display_name, password_hash, avatar_color, role) VALUES (?, ?, ?, ?, ?)') + .run(username, display_name, hash, avatarColor, 'admin'); + + res.status(201).json({ + user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, role: 'admin' }, + }); + } catch (err) { + if (err.message?.includes('UNIQUE constraint')) { + return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 }); + } + log.error('Setup error:', err); + res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); + } +}); + /** * GET /api/v1/auth/me * Response: { user: { id, username, display_name, avatar_color, role } } diff --git a/test-setup.js b/test-setup.js new file mode 100644 index 0000000..60e85af --- /dev/null +++ b/test-setup.js @@ -0,0 +1,87 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const tmpDir = mkdtempSync(join(tmpdir(), 'oikos-setup-test-')); + +process.env.SESSION_SECRET = 'test-setup-secret-minimum-32-chars-x'; +process.env.DB_PATH = join(tmpDir, 'test.db'); +process.env.SESSION_SECURE = 'false'; +process.env.PORT = '13099'; + +// Dynamic import so env vars are set before module initialization +const { default: app } = await import('./server/index.js'); +await new Promise(r => setTimeout(r, 400)); + +const BASE = 'http://localhost:13099'; + +after(() => { + rmSync(tmpDir, { recursive: true, force: true }); + process.exit(0); +}); + +// Validation tests run first (DB is empty at this point) + +test('POST /api/v1/auth/setup: 400 when required fields missing', async () => { + const res = await fetch(`${BASE}/api/v1/auth/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin' }), + }); + assert.equal(res.status, 400); +}); + +test('POST /api/v1/auth/setup: 400 when username invalid', async () => { + const res = await fetch(`${BASE}/api/v1/auth/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'a!b', display_name: 'Test', password: 'password123' }), + }); + assert.equal(res.status, 400); +}); + +test('POST /api/v1/auth/setup: 400 when password too short', async () => { + const res = await fetch(`${BASE}/api/v1/auth/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', display_name: 'Test', password: 'short' }), + }); + assert.equal(res.status, 400); +}); + +test('POST /api/v1/auth/setup: 400 when display_name too long', async () => { + const res = await fetch(`${BASE}/api/v1/auth/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', display_name: 'x'.repeat(129), password: 'password123' }), + }); + assert.equal(res.status, 400); +}); + +test('POST /api/v1/auth/setup: 201 creates first admin', async () => { + const res = await fetch(`${BASE}/api/v1/auth/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', display_name: 'Test Admin', password: 'password123' }), + }); + assert.equal(res.status, 201); + const data = await res.json(); + assert.equal(data.user.username, 'admin'); + assert.equal(data.user.display_name, 'Test Admin'); + assert.equal(data.user.role, 'admin'); + assert.ok(typeof data.user.id === 'number'); + assert.ok(typeof data.user.avatar_color === 'string' && data.user.avatar_color.startsWith('#')); +}); + +test('POST /api/v1/auth/setup: 403 when users already exist', async () => { + const res = await fetch(`${BASE}/api/v1/auth/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin2', display_name: 'Another', password: 'password123' }), + }); + assert.equal(res.status, 403); + const data = await res.json(); + assert.equal(data.code, 403); +});