diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 18fa901..407f20a 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -12,6 +12,7 @@ import { requireAuth } from './middleware/auth.js'; import { loadS3ConfigFromDb } from './s3/client.js'; import authRouter from './routes/auth.js'; +import usersRouter from './routes/users.js'; // Routes import assetsRouter from './routes/assets.js'; import projectsRouter from './routes/projects.js'; @@ -106,6 +107,7 @@ app.use('/api/v1', (req, res, next) => { // ── API Routes ──────────────────────────────────────────────────────────────── app.use('/api/v1/auth', authRouter); +app.use('/api/v1/auth/users', usersRouter); app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/bins', binsRouter); diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js new file mode 100644 index 0000000..1803335 --- /dev/null +++ b/services/mam-api/src/routes/users.js @@ -0,0 +1,72 @@ +// User CRUD. Mounted at /api/v1/auth/users by index.js (behind the auth gate). +// Flat access: any logged-in user can manage other users (spec). +import express from 'express'; +import pool from '../db/pool.js'; +import { hashPassword } from '../auth/passwords.js'; +import { DEV_USER_ID } from '../middleware/auth.js'; + +const router = express.Router(); +const MIN_PASSWORD_LEN = 12; + +function bad(res, msg) { return res.status(400).json({ error: msg }); } + +// GET / — list users (real ones; dev seed hidden) +router.get('/', async (_req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT id, username, display_name, role, last_login_at, created_at + FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]); + res.json(rows); + } catch (err) { next(err); } +}); + +// POST / — create user +router.post('/', async (req, res, next) => { + try { + const { username, password, display_name, role } = req.body || {}; + if (!username || typeof username !== 'string') return bad(res, 'username required'); + if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars'); + const hash = await hashPassword(password); + const { rows } = await pool.query( + `INSERT INTO users (username, password_hash, display_name, role) + VALUES ($1, $2, $3, $4) + RETURNING id, username, display_name, role, created_at`, + [username.trim(), hash, display_name || username.trim(), role || 'admin'] + ); + res.status(201).json(rows[0]); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'username already exists' }); + next(err); + } +}); + +// POST /:id/password — admin reset another user's password +router.post('/:id/password', async (req, res, next) => { + try { + const { new_password } = req.body || {}; + if (!new_password || new_password.length < MIN_PASSWORD_LEN) { + return bad(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' chars'); + } + const hash = await hashPassword(new_password); + const { rowCount } = await pool.query( + `UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2 AND id <> $3`, + [hash, req.params.id, DEV_USER_ID]); + if (rowCount === 0) return res.status(404).json({ error: 'user not found' }); + res.status(204).end(); + } catch (err) { next(err); } +}); + +// DELETE /:id — delete a user, except the last real user +router.delete('/:id', async (req, res, next) => { + try { + if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot delete dev user' }); + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]); + if (rows[0].n <= 1) return res.status(409).json({ error: 'cannot delete last user' }); + const { rowCount } = await pool.query(`DELETE FROM users WHERE id = $1`, [req.params.id]); + if (rowCount === 0) return res.status(404).json({ error: 'user not found' }); + res.status(204).end(); + } catch (err) { next(err); } +}); + +export default router; diff --git a/services/mam-api/test/routes/users.test.js b/services/mam-api/test/routes/users.test.js new file mode 100644 index 0000000..0c258d6 --- /dev/null +++ b/services/mam-api/test/routes/users.test.js @@ -0,0 +1,89 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import session from 'express-session'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; +import usersRouter from '../../src/routes/users.js'; +import authRouter from '../../src/routes/auth.js'; +import { requireAuth } from '../../src/middleware/auth.js'; +import { hashPassword } from '../../src/auth/passwords.js'; + +async function app(pool) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + const ConnectPg = (await import('connect-pg-simple')).default(session); + const a = express(); + a.use(express.json()); + a.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, + })); + a.use('/api/v1/auth', authRouter); + a.use('/api/v1/auth/users', requireAuth, usersRouter); + return new Promise(r => { + const srv = a.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +async function login(baseUrl, username, password) { + const r = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + assert.equal(r.status, 200, 'login failed: ' + JSON.stringify(await r.json())); + return (r.headers.get('set-cookie') || '').split(';')[0]; +} + +test('users: list + create + delete + admin reset password', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', $1)`, [await hashPassword('admin-passphrase!!')]); + const { baseUrl, close } = await app(pool); + try { + const cookie = await login(baseUrl, 'admin', 'admin-passphrase!!'); + + // List + const list = await fetch(baseUrl + '/api/v1/auth/users', { headers: { cookie } }); + assert.equal(list.status, 200); + const users0 = await list.json(); + assert.ok(users0.find(u => u.username === 'admin')); + + // Create + const created = await fetch(baseUrl + '/api/v1/auth/users', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ username: 'bob', password: 'bob-passphrase!', display_name: 'Bob' }), + }); + assert.equal(created.status, 201); + const bob = await created.json(); + assert.equal(bob.username, 'bob'); + + // Admin reset password + const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ new_password: 'a-fresh-passphrase' }), + }); + assert.equal(reset.status, 204); + + // Delete + const del = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id, { + method: 'DELETE', headers: { cookie }, + }); + assert.equal(del.status, 204); + } finally { await close(); await pool.end(); } +}); + +test('users: cannot delete the last real user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (id, username, password_hash) VALUES (uuid_generate_v4(), 'solo', $1)`, [await hashPassword('only-user-here-12')]); + const { baseUrl, close } = await app(pool); + try { + const cookie = await login(baseUrl, 'solo', 'only-user-here-12'); + const me = await (await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } })).json(); + const r = await fetch(baseUrl + '/api/v1/auth/users/' + me.id, { method: 'DELETE', headers: { cookie } }); + assert.equal(r.status, 409); + assert.equal((await r.json()).error, 'cannot delete last user'); + } finally { await close(); await pool.end(); } +});