From c9f9698b5874dcfd4659874db208c266cf3ab84d Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:24:56 -0400 Subject: [PATCH] =?UTF-8?q?feat(mam-api):=20POST=20/auth/setup=20=E2=80=94?= =?UTF-8?q?=20first-run=20admin=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mam-api/src/routes/auth.js | 40 ++++++++++++++ services/mam-api/test/routes/auth.test.js | 67 +++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 4956045..7fe6752 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -1,6 +1,7 @@ import express from 'express'; import pool from '../db/pool.js'; import { DEV_USER_ID } from '../middleware/auth.js'; +import { hashPassword } from '../auth/passwords.js'; const router = express.Router(); @@ -19,5 +20,44 @@ router.get('/setup-required', async (_req, res, next) => { } catch (err) { next(err); } }); + +const MIN_PASSWORD_LEN = 12; + +function badRequest(res, msg) { return res.status(400).json({ error: msg }); } + +// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists. +router.post('/setup', async (req, res, next) => { + try { + const { username, password } = req.body || {}; + if (!username || typeof username !== 'string') return badRequest(res, 'username required'); + if (!password || typeof password !== 'string') return badRequest(res, 'password required'); + if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters'); + + if ((await realUserCount()) > 0) { + return res.status(409).json({ error: 'setup already complete' }); + } + + const hash = await hashPassword(password); + const { rows } = await pool.query( + `INSERT INTO users (username, password_hash, display_name, role) + VALUES ($1, $2, $1, 'admin') + RETURNING id, username, display_name`, + [username.trim(), hash] + ); + const user = rows[0]; + + // Immediately log them in. + req.session.user_id = user.id; + req.session.first_seen_at = Date.now(); + req.session.last_seen_at = Date.now(); + await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); + + res.json({ user }); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'username already exists' }); + next(err); + } +}); + export default router; export { realUserCount }; diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index d0fbebe..670a677 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; import express from 'express'; +import session from 'express-session'; import authRouter from '../../src/routes/auth.js'; async function appWithAuth(pool) { @@ -35,3 +36,69 @@ test('GET /auth/setup-required returns { required: false } once a real user exis assert.deepEqual(await res.json(), { required: false }); } finally { await close(); await pool.end(); } }); + +async function appWithSession(pool) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.SESSION_SECRET = 'test'; + process.env.AUTH_ENABLED = 'true'; + const ConnectPg = (await import('connect-pg-simple')).default(session); + const app = express(); + app.use(express.json()); + app.use(session({ + store: new ConnectPg({ pool, tableName: 'sessions' }), + secret: 'test', name: 'dragonflight.sid', + cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 }, + rolling: false, resave: false, saveUninitialized: false, + })); + app.use('/api/v1/auth', authRouter); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +test('POST /auth/setup creates the first admin and returns a session cookie', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + const { baseUrl, close } = await appWithSession(pool); + try { + const res = await fetch(baseUrl + '/api/v1/auth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.user.username, 'admin'); + assert.match(res.headers.get('set-cookie') || '', /dragonflight\.sid=/); + const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE username='admin'`); + assert.equal(rows[0].n, 1); + } finally { await close(); await pool.end(); } +}); + +test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('existing', 'x')`); + const { baseUrl, close } = await appWithSession(pool); + try { + const res = await fetch(baseUrl + '/api/v1/auth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), + }); + assert.equal(res.status, 409); + assert.equal((await res.json()).error, 'setup already complete'); + } finally { await close(); await pool.end(); } +}); + +test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + const { baseUrl, close } = await appWithSession(pool); + try { + const res = await fetch(baseUrl + '/api/v1/auth/setup', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'short' }), + }); + assert.equal(res.status, 400); + } finally { await close(); await pool.end(); } +});