Files
solelog/apps/api/test/admin-users.test.ts
Bas van Rossem 9c99d56f99 test(api): Admin users — fill gaps + lifecycle test
Fill the remaining coverage gaps in src/routes/admin-users.ts and add the
domain lifecycle test, bringing the file to 100% stmts/branch/funcs/lines.

New tests (apps/api/test/admin-users.test.ts):
- password route: 404 unknown id; 400 when better-auth setUserPassword throws
  (driven via a spy so the catch path is deterministic).
- toListItem: a null DB role lists as 'worker' (nullish-coalescing fallback).
- role route: 400 on an invalid role body; 404 on an unknown id.
- reactivate route: 404 on an unknown id.

Lifecycle (apps/api/test/integration/user-lifecycle.test.ts): admin creates a
worker -> worker signs in and the bearer authorizes /api/me -> admin deactivates
(session rows deleted, so the pre-issued token 401s and fresh sign-in is blocked)
-> admin reactivates -> sign-in works again.

The two last-admin guards (role + deactivate routes) are unreachable over HTTP
(the self-demote / self-deactivate guards above already handle caller===target,
so any caller reaching them is a distinct active admin and
activeAdminsExcluding(target) >= 1). Annotated with /* c8 ignore */ + reasons
rather than left as silent gaps; the guard logic is unit-tested via
activeAdminsExcluding.

No production behavior changed; no bugs found.
2026-07-23 13:43:59 +02:00

482 lines
18 KiB
TypeScript

import { describe, it, expect, vi } 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 { auth } from '../src/auth';
import { activeAdminsExcluding } from '../src/routes/admin-users';
const PASSWORD = 'sterk-wachtwoord-123';
async function signInStatus(app: Hono, email: string, password: string): Promise<number> {
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<string> {
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<number> {
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<number> {
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);
});
it('404s on an unknown id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'pw-404-admin@example.com', 'admin');
const res = await app.request('/api/admin/users/does-not-exist/password', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ password: 'long-enough-123' }),
});
expect(res.status).toBe(404);
expect((await res.json()).error).toContain('niet gevonden');
});
// When better-auth's setUserPassword throws, the route must surface a 400 (not a 500).
it('400s when better-auth setUserPassword throws', async () => {
const app = createApp();
const adminTok = await authToken(app, 'pw-fail-admin@example.com', 'admin');
await authToken(app, 'pw-fail-target@example.com');
const id = await userIdByEmail('pw-fail-target@example.com');
const spy = vi.spyOn(auth.api, 'setUserPassword').mockRejectedValueOnce(new Error('boom'));
try {
const res = await app.request(`/api/admin/users/${id}/password`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ password: 'long-enough-123' }),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('Wachtwoord wijzigen mislukt.');
} finally {
spy.mockRestore();
}
});
});
// Gap-fill: branches not exercised by the suites above (invalid input, not-found paths,
// and the null-role list fallback in toListItem).
describe('admin-users — validation & not-found gaps', () => {
it('lists a user with a null DB role as a worker (toListItem fallback)', async () => {
const app = createApp();
const adminTok = await authToken(app, 'nullrole-admin@example.com', 'admin');
await authToken(app, 'nullrole-target@example.com');
const id = await userIdByEmail('nullrole-target@example.com');
await db.update(user).set({ role: null }).where(eq(user.id, id));
const res = await app.request('/api/admin/users', { headers: bearer(adminTok) });
const list = (await res.json()) as Array<{ id: string; role: string }>;
expect(list.find((u) => u.id === id)?.role).toBe('worker');
});
it('400s the role route on an invalid role body', async () => {
const app = createApp();
const adminTok = await authToken(app, 'role-invalid-admin@example.com', 'admin');
await authToken(app, 'role-invalid-target@example.com');
const id = await userIdByEmail('role-invalid-target@example.com');
const res = await app.request(`/api/admin/users/${id}/role`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'superuser' }),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('Invalid input');
});
it('404s the role route on an unknown id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'role-404-admin@example.com', 'admin');
const res = await app.request('/api/admin/users/does-not-exist/role', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'admin' }),
});
expect(res.status).toBe(404);
expect((await res.json()).error).toContain('niet gevonden');
});
it('404s the reactivate route on an unknown id', async () => {
const app = createApp();
const adminTok = await authToken(app, 'react-404-admin@example.com', 'admin');
const res = await app.request('/api/admin/users/does-not-exist/reactivate', {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(404);
expect((await res.json()).error).toContain('niet gevonden');
});
});