import { describe, it, expect } from 'vitest'; import type { Hono } from 'hono'; import { createApp } from '../src/app'; import { authToken, bearer } from './helpers'; import { db } from '../src/db/client'; import { user } from '../src/db/schema'; import { eq } from 'drizzle-orm'; import { activeAdminsExcluding } from '../src/routes/admin-users'; const PASSWORD = 'sterk-wachtwoord-123'; async function signInStatus(app: Hono, email: string, password: string): Promise { const res = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); return res.status; } describe('GET /api/admin/users', () => { it('401s without a token, 403s for a worker', async () => { const app = createApp(); expect((await app.request('/api/admin/users')).status).toBe(401); const workerTok = await authToken(app, 'users-list-worker@example.com'); expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe( 403, ); }); it('returns each user with role and status', async () => { const app = createApp(); const adminTok = await authToken(app, 'users-list-admin@example.com', 'admin'); await authToken(app, 'users-list-w@example.com'); // a worker const res = await app.request('/api/admin/users', { headers: bearer(adminTok) }); expect(res.status).toBe(200); const users = await res.json(); const admin = users.find((u: { email: string }) => u.email === 'users-list-admin@example.com'); const worker = users.find((u: { email: string }) => u.email === 'users-list-w@example.com'); expect(admin.role).toBe('admin'); expect(admin.status).toBe('active'); expect(worker.role).toBe('worker'); expect(worker.status).toBe('active'); }); }); describe('POST /api/admin/users', () => { it('creates a user who can then sign in', async () => { const app = createApp(); const adminTok = await authToken(app, 'users-create-admin@example.com', 'admin'); const res = await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ email: 'created-worker@example.com', name: 'Nieuwe Werker', password: PASSWORD, role: 'worker', }), }); expect(res.status).toBe(200); const created = await res.json(); expect(created.email).toBe('created-worker@example.com'); expect(created.role).toBe('worker'); expect(created.status).toBe('active'); expect(await signInStatus(app, 'created-worker@example.com', PASSWORD)).toBe(200); }); it('rejects a duplicate email with 409', async () => { const app = createApp(); const adminTok = await authToken(app, 'users-dup-admin@example.com', 'admin'); const body = JSON.stringify({ email: 'dup@example.com', name: 'Dup', password: PASSWORD, role: 'worker', }); expect( (await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body })) .status, ).toBe(200); expect( (await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body })) .status, ).toBe(409); }); it('rejects a too-short password with 400', async () => { const app = createApp(); const adminTok = await authToken(app, 'users-shortpw-admin@example.com', 'admin'); const res = await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ email: 'short@example.com', name: 'S', password: 'short', role: 'worker', }), }); expect(res.status).toBe(400); }); it('403s for a worker', async () => { const app = createApp(); const workerTok = await authToken(app, 'users-create-worker@example.com'); const res = await app.request('/api/admin/users', { method: 'POST', headers: bearer(workerTok), body: JSON.stringify({ email: 'x@example.com', name: 'X', password: PASSWORD, role: 'worker', }), }); expect(res.status).toBe(403); }); it('stores a mixed-case email lowercased and returns the row (no 500)', async () => { const app = createApp(); const adminTok = await authToken(app, 'mixed-admin@example.com', 'admin'); const res = await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ email: 'Mixed.Case@Example.com', name: 'Mixed', password: PASSWORD, role: 'worker', }), }); expect(res.status).toBe(200); expect((await res.json()).email).toBe('mixed.case@example.com'); }); }); async function userIdByEmail(email: string): Promise { const [row] = await db.select().from(user).where(eq(user.email, email)); return row.id; } describe('POST /api/admin/users/:id/role', () => { it('promotes a worker to admin and back', async () => { const app = createApp(); const adminTok = await authToken(app, 'role-admin@example.com', 'admin'); await authToken(app, 'role-target@example.com'); // worker const id = await userIdByEmail('role-target@example.com'); const up = await app.request(`/api/admin/users/${id}/role`, { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ role: 'admin' }), }); expect(up.status).toBe(200); expect((await up.json()).role).toBe('admin'); const down = await app.request(`/api/admin/users/${id}/role`, { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ role: 'worker' }), }); expect((await down.json()).role).toBe('worker'); }); it('refuses self-demotion', async () => { const app = createApp(); const adminTok = await authToken(app, 'role-self@example.com', 'admin'); // a second admin so the last-admin guard is not what trips first await authToken(app, 'role-self-other-admin@example.com', 'admin'); const id = await userIdByEmail('role-self@example.com'); const res = await app.request(`/api/admin/users/${id}/role`, { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ role: 'worker' }), }); expect(res.status).toBe(400); expect((await res.json()).error).toContain('degraderen'); }); // Demoting *another* admin is allowed while the acting admin remains active — this is the // expected 200 path. The genuine last-admin guard logic is covered as a unit test of // activeAdminsExcluding below (the route branch is unreachable over HTTP because the caller // is always an active admin and never the target). it('allows demoting another admin while the caller stays admin', async () => { const app = createApp(); const adminTok = await authToken(app, 'role-peer-admin@example.com', 'admin'); await authToken(app, 'role-peer@example.com', 'admin'); const peerId = await userIdByEmail('role-peer@example.com'); const res = await app.request(`/api/admin/users/${peerId}/role`, { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ role: 'worker' }), }); expect(res.status).toBe(200); expect((await res.json()).role).toBe('worker'); }); }); // Direct unit coverage of the last-admin invariant helper (the route branch that uses it is // unreachable over HTTP, so this is where its !banned filter + exclusion logic is actually tested). describe('activeAdminsExcluding', () => { it('counts only active (non-banned) admins and honours the exclusion', async () => { const app = createApp(); const before = await activeAdminsExcluding('___none___'); await authToken(app, 'aae-fresh-admin@example.com', 'admin'); expect(await activeAdminsExcluding('___none___')).toBe(before + 1); // a new active admin counts const freshId = await userIdByEmail('aae-fresh-admin@example.com'); expect(await activeAdminsExcluding(freshId)).toBe(before); // excluding it drops the count back await db.update(user).set({ banned: true }).where(eq(user.id, freshId)); expect(await activeAdminsExcluding('___none___')).toBe(before); // a banned admin is not counted }); }); describe('adminGuard rejects banned admins', () => { it('403s a banned admin that still holds a valid token', async () => { const app = createApp(); const adminTok = await authToken(app, 'banned-admin@example.com', 'admin'); expect((await app.request('/api/admin/users', { headers: bearer(adminTok) })).status).toBe(200); // Ban directly (not via /deactivate, which would also delete the session) so the token stays valid. const id = await userIdByEmail('banned-admin@example.com'); await db.update(user).set({ banned: true }).where(eq(user.id, id)); expect((await app.request('/api/admin/users', { headers: bearer(adminTok) })).status).toBe(403); }); }); describe('deactivate / reactivate', () => { async function signIn(app: Hono, email: string, password: string): Promise { const res = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); return res.status; } it('blocks sign-in after deactivate and restores it after reactivate', async () => { const app = createApp(); const adminTok = await authToken(app, 'deact-admin@example.com', 'admin'); // create a worker we control the password of await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ email: 'deact-target@example.com', name: 'Deact', password: 'sterk-wachtwoord-123', role: 'worker', }), }); const id = await userIdByEmail('deact-target@example.com'); expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200); const off = await app.request(`/api/admin/users/${id}/deactivate`, { method: 'POST', headers: bearer(adminTok), }); expect(off.status).toBe(200); expect((await off.json()).status).toBe('inactive'); expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).not.toBe(200); const on = await app.request(`/api/admin/users/${id}/reactivate`, { method: 'POST', headers: bearer(adminTok), }); expect((await on.json()).status).toBe('active'); expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200); }); it('refuses self-deactivation', async () => { const app = createApp(); const adminTok = await authToken(app, 'deact-self@example.com', 'admin'); await authToken(app, 'deact-self-other@example.com', 'admin'); // a second admin const id = await userIdByEmail('deact-self@example.com'); const res = await app.request(`/api/admin/users/${id}/deactivate`, { method: 'POST', headers: bearer(adminTok), }); expect(res.status).toBe(400); expect((await res.json()).error).toContain('deactiveren'); }); // Deactivating another admin is allowed (the caller remains); the caller can never deactivate // themselves even as the sole remaining active admin — together these preserve the invariant. it('allows deactivating another admin but always blocks self-deactivate', async () => { const app = createApp(); const adminTok = await authToken(app, 'deact-last-admin@example.com', 'admin'); await authToken(app, 'deact-peer@example.com', 'admin'); const peerId = await userIdByEmail('deact-peer@example.com'); const res = await app.request(`/api/admin/users/${peerId}/deactivate`, { method: 'POST', headers: bearer(adminTok), }); expect(res.status).toBe(200); // caller still admin → allowed // The caller is now the only active admin; deactivating self is refused. const callerId = await userIdByEmail('deact-last-admin@example.com'); const selfRes = await app.request(`/api/admin/users/${callerId}/deactivate`, { method: 'POST', headers: bearer(adminTok), }); expect(selfRes.status).toBe(400); }); it('404s on an unknown id and 403s for a worker', async () => { const app = createApp(); const adminTok = await authToken(app, 'deact-404-admin@example.com', 'admin'); expect( ( await app.request('/api/admin/users/nope/deactivate', { method: 'POST', headers: bearer(adminTok), }) ).status, ).toBe(404); const workerTok = await authToken(app, 'deact-worker@example.com'); const id = await userIdByEmail('deact-worker@example.com'); expect( ( await app.request(`/api/admin/users/${id}/deactivate`, { method: 'POST', headers: bearer(workerTok), }) ).status, ).toBe(403); }); }); describe('POST /api/admin/users/:id/password', () => { async function signIn(app: Hono, email: string, password: string): Promise { const res = await app.request('/api/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); return res.status; } it('sets a new password: new works, old fails', async () => { const app = createApp(); const adminTok = await authToken(app, 'pw-admin@example.com', 'admin'); await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ email: 'pw-target@example.com', name: 'Pw', password: 'old-wachtwoord-123', role: 'worker', }), }); const id = await userIdByEmail('pw-target@example.com'); const res = await app.request(`/api/admin/users/${id}/password`, { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ password: 'new-wachtwoord-456' }), }); expect(res.status).toBe(200); expect(await signIn(app, 'pw-target@example.com', 'new-wachtwoord-456')).toBe(200); expect(await signIn(app, 'pw-target@example.com', 'old-wachtwoord-123')).not.toBe(200); }); it('rejects a too-short password with 400 and 403s for a worker', async () => { const app = createApp(); const adminTok = await authToken(app, 'pw-short-admin@example.com', 'admin'); await authToken(app, 'pw-worker@example.com'); const id = await userIdByEmail('pw-worker@example.com'); expect( ( await app.request(`/api/admin/users/${id}/password`, { method: 'POST', headers: bearer(adminTok), body: JSON.stringify({ password: 'short' }), }) ).status, ).toBe(400); const workerTok = await authToken(app, 'pw-worker2@example.com'); expect( ( await app.request(`/api/admin/users/${id}/password`, { method: 'POST', headers: bearer(workerTok), body: JSON.stringify({ password: 'long-enough-123' }), }) ).status, ).toBe(403); }); });