feat(api): deactivate/reactivate users (ban + revoke sessions) with guards

This commit is contained in:
Bas van Rossem
2026-06-24 22:54:02 +02:00
parent 88b7773317
commit 40977f8abd
2 changed files with 133 additions and 0 deletions

View File

@@ -193,3 +193,103 @@ describe('POST /api/admin/users/:id/role', () => {
expect(res.status).toBe(200);
});
});
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');
});
it('refuses deactivating the last active admin', async () => {
const app = createApp();
const adminTok = await authToken(app, 'deact-last-admin@example.com', 'admin');
// promote a peer, deactivate caller is self-blocked; deactivate the peer is allowed (caller remains).
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
// Now the peer is inactive; the caller is the only active admin. Deactivating self is blocked:
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); // self-guard (which also preserves the last admin)
});
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);
});
});