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,96 @@
|
||||
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||
|
||||
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||
countActiveAdmins: vi.fn(),
|
||||
findUserByEmail: vi.fn(),
|
||||
grantPermission: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/models/admin/invitations/invitations.service.js', () => ({
|
||||
hasOpenInvitationFor: vi.fn(),
|
||||
createInvitation: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/models/admin/admin.mail.js', () => ({
|
||||
sendInvitationMail: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/models/admin/admin.config.js', () => ({
|
||||
ADMIN_BOOTSTRAP_EMAIL: 'boss@nachklang.art',
|
||||
ADMIN_APP_URL: 'http://localhost:3002',
|
||||
isProd: false
|
||||
}));
|
||||
|
||||
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
|
||||
import {sendInvitationMail} from '../../src/models/admin/admin.mail.js';
|
||||
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
|
||||
|
||||
const countActiveAdmins = UsersService.countActiveAdmins as Mock;
|
||||
const findUserByEmail = UsersService.findUserByEmail as Mock;
|
||||
const grantPermission = UsersService.grantPermission as Mock;
|
||||
const hasOpenInvitationFor = InvitationsService.hasOpenInvitationFor as Mock;
|
||||
const createInvitation = InvitationsService.createInvitation as Mock;
|
||||
const mockMail = sendInvitationMail as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
countActiveAdmins.mockReset();
|
||||
findUserByEmail.mockReset();
|
||||
grantPermission.mockReset();
|
||||
hasOpenInvitationFor.mockReset();
|
||||
createInvitation.mockReset();
|
||||
mockMail.mockReset();
|
||||
mockMail.mockResolvedValue(true);
|
||||
createInvitation.mockResolvedValue({id: 1, token: 'raw-token', expiresAt: new Date()});
|
||||
});
|
||||
|
||||
describe('bootstrapAdmin', () => {
|
||||
it('does nothing when an active admin already exists', async () => {
|
||||
countActiveAdmins.mockResolvedValue(1);
|
||||
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect(createInvitation).not.toHaveBeenCalled();
|
||||
expect(grantPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grants admin directly when the bootstrap address is already a user', async () => {
|
||||
countActiveAdmins.mockResolvedValue(0);
|
||||
findUserByEmail.mockResolvedValue({id: 'u9', email: 'boss@nachklang.art'});
|
||||
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect(grantPermission).toHaveBeenCalledWith('u9', 'admin', null);
|
||||
expect(createInvitation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-invite (or re-mail) while an open invitation exists', async () => {
|
||||
countActiveAdmins.mockResolvedValue(0);
|
||||
findUserByEmail.mockResolvedValue(null);
|
||||
hasOpenInvitationFor.mockResolvedValue(true);
|
||||
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect(createInvitation).not.toHaveBeenCalled();
|
||||
expect(mockMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invites with the admin permission when there is nothing to work with', async () => {
|
||||
countActiveAdmins.mockResolvedValue(0);
|
||||
findUserByEmail.mockResolvedValue(null);
|
||||
hasOpenInvitationFor.mockResolvedValue(false);
|
||||
|
||||
await bootstrapAdmin();
|
||||
|
||||
expect(createInvitation).toHaveBeenCalledWith(
|
||||
'boss@nachklang.art',
|
||||
'Nachklang Admin',
|
||||
[{app: 'admin', role: 'access'}],
|
||||
null
|
||||
);
|
||||
expect(mockMail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never throws when the database is unreachable at boot', async () => {
|
||||
countActiveAdmins.mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
|
||||
await expect(bootstrapAdmin()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
// admin.config calls dotenv.config(), which would read the repo's own .env and
|
||||
// quietly reintroduce NODE_ENV=development - the exact value several of these
|
||||
// cases exist to remove. Stub it so the tests see only what they set.
|
||||
vi.mock('dotenv', () => ({config: vi.fn()}));
|
||||
|
||||
/**
|
||||
* admin.config reads the environment once at import, so every case here has to
|
||||
* reset the module registry and re-import it. The two things worth pinning are
|
||||
* the ones that are silent when wrong: which client-IP header is trusted, and
|
||||
* whether an unset NODE_ENV counts as production.
|
||||
*/
|
||||
|
||||
const ORIGINAL_ENV = {...process.env};
|
||||
|
||||
const loadConfig = async () => {
|
||||
vi.resetModules();
|
||||
return import('../../src/models/admin/admin.config.js');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {...ORIGINAL_ENV};
|
||||
// dotenv.config() in admin.config does not overwrite what is already set,
|
||||
// so setting these here is enough to keep the local .env out of the test.
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.CLIENT_IP_HEADERS;
|
||||
delete process.env.TRUSTED_PROXY_IPS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = {...ORIGINAL_ENV};
|
||||
});
|
||||
|
||||
describe('CLIENT_IP_HEADERS', () => {
|
||||
it('defaults to the single header Plesk nginx sets', async () => {
|
||||
const config = await loadConfig();
|
||||
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
|
||||
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a comma-separated list', async () => {
|
||||
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
|
||||
const config = await loadConfig();
|
||||
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
|
||||
});
|
||||
|
||||
it('trusts nothing when set to "none"', async () => {
|
||||
// The escape hatch. An empty list is what better-auth reads as "no
|
||||
// headers" - it only falls back to its own default when the option is
|
||||
// absent - so this really does stop any header being believed.
|
||||
process.env.CLIENT_IP_HEADERS = 'none';
|
||||
const config = await loadConfig();
|
||||
expect(config.CLIENT_IP_HEADERS).toEqual([]);
|
||||
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
|
||||
process.env.CLIENT_IP_HEADERS = ' NONE ';
|
||||
const config = await loadConfig();
|
||||
expect(config.CLIENT_IP_HEADERS).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats an empty value as "use the default", not as the hatch', async () => {
|
||||
// A blank line in a .env must not silently change how requests are
|
||||
// bucketed - only the explicit word does that.
|
||||
process.env.CLIENT_IP_HEADERS = '';
|
||||
const config = await loadConfig();
|
||||
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
|
||||
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mistake a header actually named none-ish for the hatch', async () => {
|
||||
process.env.CLIENT_IP_HEADERS = 'x-none';
|
||||
const config = await loadConfig();
|
||||
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
|
||||
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProd', () => {
|
||||
it('is false only for the explicit relaxed environments', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect((await loadConfig()).isProd).toBe(false);
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
expect((await loadConfig()).isProd).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
|
||||
delete process.env.NODE_ENV;
|
||||
// Strict mode refuses to boot without these; supply them so the import
|
||||
// gets far enough to answer the question being asked.
|
||||
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
|
||||
process.env.API_BASE_URL = 'https://api.nachklang.art';
|
||||
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
||||
|
||||
expect((await loadConfig()).isProd).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to start without a signing key outside development', async () => {
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
process.env.API_BASE_URL = 'https://api.nachklang.art';
|
||||
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
|
||||
|
||||
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||
|
||||
vi.mock('../../src/common/common.mail.js', () => ({
|
||||
MailService: {sendMail: vi.fn()}
|
||||
}));
|
||||
vi.mock('../../src/models/admin/admin.config.js', () => ({
|
||||
ADMIN_APP_URL: 'https://admin.nachklang.art'
|
||||
}));
|
||||
|
||||
import {MailService} from '../../src/common/common.mail.js';
|
||||
import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js';
|
||||
|
||||
const sendMail = MailService.sendMail as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
sendMail.mockReset();
|
||||
sendMail.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('sendInvitationMail', () => {
|
||||
it('points at the admin app and carries the token in the query string', async () => {
|
||||
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z'));
|
||||
|
||||
const [to, subject, text, options] = sendMail.mock.calls[0];
|
||||
expect(to).toBe('a@nachklang.art');
|
||||
expect(subject).toBeTruthy();
|
||||
expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
|
||||
expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
|
||||
});
|
||||
|
||||
it('url-encodes a token containing url-significant characters', async () => {
|
||||
await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date());
|
||||
|
||||
const [, , text] = sendMail.mock.calls[0];
|
||||
expect(text).toContain('token=a%2Bb%2Fc%3Dd');
|
||||
});
|
||||
|
||||
it('sends both a text and an html part', async () => {
|
||||
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date());
|
||||
|
||||
const [, , text, options] = sendMail.mock.calls[0];
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(options.html).toContain('<html');
|
||||
});
|
||||
|
||||
it('escapes a name that contains html', async () => {
|
||||
await sendInvitationMail('a@nachklang.art', '<script>alert(1)</script>', 'tok', new Date());
|
||||
|
||||
const [, , , options] = sendMail.mock.calls[0];
|
||||
expect(options.html).not.toContain('<script>');
|
||||
expect(options.html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('reports a delivery failure to the caller rather than throwing', async () => {
|
||||
sendMail.mockResolvedValue(false);
|
||||
|
||||
await expect(sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date())).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPasswordResetMail', () => {
|
||||
it('uses the url better-auth generated, unchanged', async () => {
|
||||
const url = 'https://api.nachklang.art/admin/auth/reset-password/abc?callbackURL=x';
|
||||
|
||||
await sendPasswordResetMail('a@nachklang.art', 'Anna', url);
|
||||
|
||||
const [, , text, options] = sendMail.mock.calls[0];
|
||||
expect(text).toContain(url);
|
||||
expect(options.html).toContain('https://api.nachklang.art/admin/auth/reset-password/abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
|
||||
import {Request, Response} from 'express';
|
||||
|
||||
vi.mock('../../src/models/admin/admin.auth.js', () => ({
|
||||
auth: {api: {getSession: vi.fn()}}
|
||||
}));
|
||||
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
|
||||
loadAccess: vi.fn()
|
||||
}));
|
||||
|
||||
import {auth} from '../../src/models/admin/admin.auth.js';
|
||||
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
|
||||
import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models/admin/admin.middleware.js';
|
||||
|
||||
const mockGetSession = auth.api.getSession as unknown as Mock;
|
||||
const mockLoadAccess = UsersService.loadAccess as Mock;
|
||||
|
||||
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
|
||||
|
||||
const makeRes = (): Response => {
|
||||
const res: any = {};
|
||||
res.status = vi.fn().mockReturnValue(res);
|
||||
res.send = vi.fn().mockReturnValue(res);
|
||||
res.locals = {};
|
||||
return res as Response;
|
||||
};
|
||||
|
||||
const activeUser = {
|
||||
id: 'u1',
|
||||
email: 'a@nachklang.art',
|
||||
displayName: 'A',
|
||||
disabled: false,
|
||||
permissions: [
|
||||
{app: 'feedback', role: 'access'},
|
||||
{app: 'admin', role: 'access'}
|
||||
],
|
||||
apps: ['feedback', 'admin']
|
||||
};
|
||||
|
||||
describe('resolveAccess', () => {
|
||||
beforeEach(() => {
|
||||
mockGetSession.mockReset();
|
||||
mockLoadAccess.mockReset();
|
||||
});
|
||||
|
||||
it('returns null without a valid session', async () => {
|
||||
mockGetSession.mockResolvedValue(null);
|
||||
expect(await resolveAccess(makeReq())).toBeNull();
|
||||
expect(mockLoadAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when the session points at a user row that is gone', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue(null);
|
||||
expect(await resolveAccess(makeReq())).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves identity and permissions in a single permission query', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue(activeUser);
|
||||
|
||||
expect(await resolveAccess(makeReq())).toEqual({
|
||||
id: 'u1',
|
||||
email: 'a@nachklang.art',
|
||||
displayName: 'A',
|
||||
disabled: false,
|
||||
permissions: [
|
||||
{app: 'feedback', role: 'access'},
|
||||
{app: 'admin', role: 'access'}
|
||||
],
|
||||
apps: ['feedback', 'admin']
|
||||
});
|
||||
// No cookieCache: exactly one lookup per request, never zero.
|
||||
expect(mockLoadAccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireSignedIn', () => {
|
||||
beforeEach(() => {
|
||||
mockGetSession.mockReset();
|
||||
mockLoadAccess.mockReset();
|
||||
});
|
||||
|
||||
it('401s without a session', async () => {
|
||||
mockGetSession.mockResolvedValue(null);
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireSignedIn(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403s a disabled user that still holds a valid cookie', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireSignedIn(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admits a signed-in user with no app permissions at all', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({...activeUser, apps: []});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireSignedIn(makeReq(), res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.locals.admin.apps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireAppAccess', () => {
|
||||
beforeEach(() => {
|
||||
mockGetSession.mockReset();
|
||||
mockLoadAccess.mockReset();
|
||||
});
|
||||
|
||||
it('401s without a session', async () => {
|
||||
mockGetSession.mockResolvedValue(null);
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('403s a signed-in user without that app permission', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({
|
||||
...activeUser,
|
||||
permissions: [{app: 'feedback', role: 'access'}],
|
||||
apps: ['feedback']
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('tickets')(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403s a disabled user even when they hold the permission', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('passes through and exposes the identity the feedback/tickets services expect', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue(activeUser);
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'A'});
|
||||
});
|
||||
|
||||
it('500s (never allows through) when the permission query throws', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockRejectedValue(new Error('db down'));
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('feedback')(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The seam a finer per-app permission arrives through. Nothing passes a role
|
||||
// today, so these two pin the behaviour before there is anything to break.
|
||||
it('403s when a specific role is required and the user only holds another', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({
|
||||
...activeUser,
|
||||
permissions: [{app: 'tickets', role: 'access'}],
|
||||
apps: ['tickets']
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when the user holds exactly the required role', async () => {
|
||||
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
|
||||
mockLoadAccess.mockResolvedValue({
|
||||
...activeUser,
|
||||
permissions: [
|
||||
{app: 'tickets', role: 'access'},
|
||||
{app: 'tickets', role: 'refund'}
|
||||
],
|
||||
apps: ['tickets']
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
|
||||
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
ACCESS_ROLE,
|
||||
appsOf,
|
||||
isAppPermission,
|
||||
isAppRole,
|
||||
toPermissions
|
||||
} from '../../src/models/admin/admin.schema.js';
|
||||
|
||||
/**
|
||||
* The permission model is (app, role). These tests pin the two properties the
|
||||
* rest of the module leans on: that the older `['tickets']` shape still means
|
||||
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
|
||||
*/
|
||||
|
||||
describe('toPermissions', () => {
|
||||
it('reads the full (app, role) form', () => {
|
||||
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
|
||||
{app: 'tickets', role: 'access'}
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a plain app list as that app at the access role', () => {
|
||||
expect(toPermissions(['feedback', 'admin'])).toEqual([
|
||||
{app: 'feedback', role: ACCESS_ROLE},
|
||||
{app: 'admin', role: ACCESS_ROLE}
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
|
||||
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
|
||||
{app: 'feedback', role: ACCESS_ROLE},
|
||||
{app: 'tickets', role: ACCESS_ROLE}
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops duplicates of the same (app, role)', () => {
|
||||
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
|
||||
{app: 'tickets', role: ACCESS_ROLE}
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects rather than silently dropping an unknown app', () => {
|
||||
// Silently ignoring it would let "grant calendar + nonsense" look like a
|
||||
// success while granting less than the caller asked for.
|
||||
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an unknown role', () => {
|
||||
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects anything that is not a list', () => {
|
||||
expect(toPermissions('admin')).toBeNull();
|
||||
expect(toPermissions(null)).toBeNull();
|
||||
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
|
||||
});
|
||||
|
||||
it('reads an empty list as "no permissions", not as invalid', () => {
|
||||
expect(toPermissions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAppRole', () => {
|
||||
it('accepts the access role for every app', () => {
|
||||
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
|
||||
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a role that does not exist yet', () => {
|
||||
expect(isAppRole('tickets', 'refund')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAppPermission', () => {
|
||||
it('needs both halves to be valid', () => {
|
||||
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
|
||||
expect(isAppPermission({app: 'tickets'})).toBe(false);
|
||||
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
|
||||
expect(isAppPermission(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appsOf', () => {
|
||||
it('collapses several roles on one app to a single entry', () => {
|
||||
// The point of the derived list: a user with two roles on tickets has
|
||||
// access to tickets once, not twice.
|
||||
const apps = appsOf([
|
||||
{app: 'tickets', role: ACCESS_ROLE},
|
||||
{app: 'tickets', role: 'future-role'},
|
||||
{app: 'admin', role: ACCESS_ROLE}
|
||||
]);
|
||||
expect(apps).toEqual(['tickets', 'admin']);
|
||||
});
|
||||
|
||||
it('is empty for no permissions', () => {
|
||||
expect(appsOf([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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