feat(mam-api): user CRUD + admin password reset + last-user delete guard
This commit is contained in:
parent
0bbaf80d2a
commit
b7f5a84d2d
3 changed files with 163 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
72
services/mam-api/src/routes/users.js
Normal file
72
services/mam-api/src/routes/users.js
Normal file
|
|
@ -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;
|
||||
89
services/mam-api/test/routes/users.test.js
Normal file
89
services/mam-api/test/routes/users.test.js
Normal file
|
|
@ -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(); }
|
||||
});
|
||||
Loading…
Reference in a new issue