dbcd5b56f6
Six defects found by an independent review of 7aac07a.
Environment handling now fails safe. NODE_ENV=production was gating the
signing key, the cookie domain, the CORS origin list and invitation-token
logging all at once, and it was documented nowhere - an unset value, which is
what a fresh Plesk vhost gives you, silently degraded all four. Only
'development' and 'test' relax anything now; everything else, unset included,
is strict. The hardcoded fallback secret is gone (dev gets a random
per-process one, so no committed value can ever sign a production cookie),
and invitation-link logging is an explicit ADMIN_LOG_INVITE_LINKS opt-in that
is refused in strict mode.
Rate limiting no longer collapses into a single global bucket. Without
trustedProxies, better-auth rejects a multi-value x-forwarded-for, resolves no
client IP, and keys every request to "no-trusted-ip" - where /sign-in/*
allows 3 requests per 10 seconds, so one noisy client could lock the whole
organisation out. CLIENT_IP_HEADERS and TRUSTED_PROXY_IPS make this explicit,
the unspecified x-forwarded-for fallback is gone, and strict mode warns at
boot when no trusted proxy is configured.
Invite acceptance is transactional. The user and its credential account go in
one runWithTransaction, as better-auth's own sign-up route does. A transaction
cannot span the permission and invitation writes - those use this module's own
pool - so a failure there is compensated: the user row is deleted and the
invitation un-marked, so the link works again instead of leaving the invitee
with a burnt token and an account no route can repair.
The last-admin guards were check-then-act. Two admins each removing the
other's admin permission could both pass the check and both commit, leaving
nobody able to administer anything. The count now runs inside the write
transaction under SELECT ... FOR UPDATE.
Also: lastSignInAt filtered expired sessions in the detail endpoint but not
the list, so the two disagreed; and the integration suite never reset
rateLimit, leaving it one added sign-in away from 429s that look like auth
bugs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
5.8 KiB
TypeScript
166 lines
5.8 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', ['tickets'], 'me');
|
|
});
|
|
|
|
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', ['feedback', 'tickets'], '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');
|
|
});
|
|
});
|