feat(auth): admin user management service, routes, and integration tests

This commit is contained in:
2026-05-20 22:58:29 +02:00
parent 70658556aa
commit 9ca025f128
3 changed files with 241 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
import { makeTestDb, createUserDirect } from './dbHelper.js';
import { hashPassword } from '../services/auth/passwords.js';
import { setMailerForTests, type Mailer } from '../services/auth/email.js';
class CaptureMailer implements Mailer {
sent: { to: string; subject: string; text: string }[] = [];
async send(to: string, m: { subject: string; html: string; text: string }) {
this.sent.push({ to, subject: m.subject, text: m.text });
}
}
async function loginAs(app: ReturnType<typeof createApp>, email: string, password = 'secretpass') {
const r = await request(app).post('/api/auth/login').send({ email, password });
if (r.status !== 200) throw new Error(`login failed: ${r.status} ${JSON.stringify(r.body)}`);
const cookies = (r.headers['set-cookie'] as unknown as string[]);
const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!;
return { cookies, csrf };
}
async function makeAdmin(env: ReturnType<typeof makeTestDb>, email: string) {
return createUserDirect(env.db, {
email, role: 'sysadmin', isActive: true,
passwordHash: await hashPassword('secretpass'),
emailVerifiedAt: Math.floor(Date.now() / 1000),
});
}
let env: ReturnType<typeof makeTestDb>;
let mailer: CaptureMailer;
let app: ReturnType<typeof createApp>;
beforeEach(async () => {
env = makeTestDb();
mailer = new CaptureMailer();
setMailerForTests(mailer);
app = createApp(env.db);
});
describe('admin users', () => {
it('invites a user, target accepts and can log in', async () => {
await makeAdmin(env, 'admin@example.com');
const { cookies, csrf } = await loginAs(app, 'admin@example.com');
const inv = await request(app).post('/api/admin/users/invite')
.set('Cookie', cookies).set('x-csrf-token', csrf)
.send({ email: 'newbie@example.com', role: 'user' });
expect(inv.status).toBe(201);
expect(mailer.sent).toHaveLength(1);
const link = mailer.sent[0]!.text;
const token = decodeURIComponent(link.match(/token=([^\s&"]+)/)![1]!);
const accept = await request(app).post('/api/auth/accept-invite')
.send({ token, displayName: 'Newbie', password: 'anotherpass' });
expect(accept.status).toBe(200);
expect(accept.body.email).toBe('newbie@example.com');
});
it('lists users with filtering', async () => {
await makeAdmin(env, 'admin@example.com');
await createUserDirect(env.db, { email: 'a@example.com', role: 'user' });
await createUserDirect(env.db, { email: 'b@example.com', role: 'user' });
const { cookies } = await loginAs(app, 'admin@example.com');
const list = await request(app).get('/api/admin/users?q=a').set('Cookie', cookies);
expect(list.status).toBe(200);
expect(list.body.rows.find((u: { email: string }) => u.email === 'a@example.com')).toBeTruthy();
});
it('blocks demoting the last sysadmin', async () => {
const admin = await makeAdmin(env, 'admin@example.com');
const { cookies, csrf } = await loginAs(app, 'admin@example.com');
const r = await request(app).patch(`/api/admin/users/${admin.id}`)
.set('Cookie', cookies).set('x-csrf-token', csrf)
.send({ role: 'user' });
expect(r.status).toBe(409);
expect(r.body.error.code).toBe('LAST_SYSADMIN');
});
it('rejects non-admin access to admin routes', async () => {
await createUserDirect(env.db, {
email: 'plain@example.com', role: 'user', isActive: true,
passwordHash: await hashPassword('secretpass'),
emailVerifiedAt: Math.floor(Date.now() / 1000),
});
const { cookies } = await loginAs(app, 'plain@example.com');
const r = await request(app).get('/api/admin/users').set('Cookie', cookies);
expect(r.status).toBe(403);
});
it('blocks unauthenticated access to admin routes', async () => {
const r = await request(app).get('/api/admin/users');
expect(r.status).toBe(401);
});
});