import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest'; import express from 'express'; import request from 'supertest'; vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({ listUsers: vi.fn(), getUserDetail: vi.fn(), loadAccess: vi.fn(), setPermissions: vi.fn(), setPermissionsGuarded: vi.fn(), disableUser: vi.fn(), disableUserGuarded: vi.fn(), enableUser: vi.fn(), revokeSession: vi.fn(), countActiveAdmins: vi.fn(), userExists: vi.fn() })); import * as UsersService from '../../src/models/admin/users/users.admin.service.js'; import {usersAdminRouter} from '../../src/models/admin/users/users.admin.router.js'; const service = UsersService as unknown as Record; // The router always runs behind requireAppAccess('admin'), which is what puts // res.locals.admin there; this stands in for it. const makeApp = (callerId = 'me') => { const app = express(); app.use(express.json()); app.use((req, res, next) => { res.locals.admin = {id: callerId, email: 'me@nachklang.art', displayName: 'Me', apps: ['admin']}; next(); }); app.use('/admin/users', usersAdminRouter); return app; }; beforeEach(() => { for (const fn of Object.values(service)) { if (typeof fn?.mockReset === 'function') { fn.mockReset(); } } service.getUserDetail.mockResolvedValue({id: 'other', apps: []}); service.userExists.mockResolvedValue(true); service.setPermissionsGuarded.mockResolvedValue('ok'); service.disableUserGuarded.mockResolvedValue('ok'); }); describe('PUT /admin/users/:id/permissions', () => { it('rejects an unknown app name', async () => { const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']}); expect(res.status).toBe(400); expect(service.setPermissionsGuarded).not.toHaveBeenCalled(); }); it('rejects a non-array body', async () => { const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: 'admin'}); expect(res.status).toBe(400); }); it('404s for an unknown user', async () => { service.userExists.mockResolvedValue(false); const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []}); expect(res.status).toBe(404); expect(service.setPermissionsGuarded).not.toHaveBeenCalled(); }); it('refuses to remove the caller\'s own admin permission', async () => { service.loadAccess.mockResolvedValue({id: 'me', disabled: false, apps: ['admin']}); service.countActiveAdmins.mockResolvedValue(5); const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']}); expect(res.status).toBe(409); expect(service.setPermissionsGuarded).not.toHaveBeenCalled(); }); // The last-admin decision is made inside the write transaction (so two // admins acting at once cannot both pass a check-then-act); the router's // job is only to turn that verdict into a 409. it('answers 409 when the service reports the last admin would be removed', async () => { service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']}); service.setPermissionsGuarded.mockResolvedValue('last-admin'); const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []}); expect(res.status).toBe(409); }); it('allows removing an admin while another active admin remains', async () => { service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']}); const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']}); expect(res.status).toBe(200); expect(service.setPermissionsGuarded).toHaveBeenCalledWith( 'other', [{app: 'tickets', role: 'access'}], 'me' ); }); it('accepts the richer {permissions} body', async () => { service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []}); const res = await request(makeApp('me')) .put('/admin/users/other/permissions') .send({permissions: [{app: 'tickets', role: 'access'}]}); expect(res.status).toBe(200); expect(service.setPermissionsGuarded).toHaveBeenCalledWith( 'other', [{app: 'tickets', role: 'access'}], 'me' ); }); it('rejects a role that does not exist', async () => { service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []}); const res = await request(makeApp('me')) .put('/admin/users/other/permissions') .send({permissions: [{app: 'tickets', role: 'refund'}]}); expect(res.status).toBe(400); expect(service.setPermissionsGuarded).not.toHaveBeenCalled(); }); it('allows granting permissions to someone who has none', async () => { service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []}); const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']}); expect(res.status).toBe(200); expect(service.setPermissionsGuarded).toHaveBeenCalledWith( 'other', [{app: 'feedback', role: 'access'}, {app: 'tickets', role: 'access'}], 'me' ); }); }); describe('POST /admin/users/:id/disable', () => { it('refuses to disable the caller', async () => { const res = await request(makeApp('me')).post('/admin/users/me/disable'); expect(res.status).toBe(409); expect(service.disableUserGuarded).not.toHaveBeenCalled(); }); it('answers 409 when the service reports the last active admin would be disabled', async () => { service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']}); service.disableUserGuarded.mockResolvedValue('last-admin'); const res = await request(makeApp('me')).post('/admin/users/other/disable'); expect(res.status).toBe(409); }); it('disables a non-admin user', async () => { service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['feedback']}); const res = await request(makeApp('me')).post('/admin/users/other/disable'); expect(res.status).toBe(200); expect(service.disableUserGuarded).toHaveBeenCalledWith('other'); }); it('404s for an unknown user', async () => { service.loadAccess.mockResolvedValue(null); const res = await request(makeApp('me')).post('/admin/users/ghost/disable'); expect(res.status).toBe(404); }); }); describe('DELETE /admin/users/:id/sessions/:sid', () => { it('404s when the session does not belong to that user', async () => { service.revokeSession.mockResolvedValue(false); const res = await request(makeApp()).delete('/admin/users/other/sessions/s1'); expect(res.status).toBe(404); }); it('204s on a successful revoke', async () => { service.revokeSession.mockResolvedValue(true); const res = await request(makeApp()).delete('/admin/users/other/sessions/s1'); expect(res.status).toBe(204); expect(service.revokeSession).toHaveBeenCalledWith('other', 's1'); }); });