Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user