bf7be65b03
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
200 lines
6.7 KiB
TypeScript
200 lines
6.7 KiB
TypeScript
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<string, Mock>;
|
|
|
|
// 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');
|
|
});
|
|
});
|