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.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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';
|
||||
@@ -391,4 +392,90 @@ describe('POST /api/admin/users/:id/password', () => {
|
||||
).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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user