Files
webmail/app/api/admin/__tests__/read-only-config.test.ts

126 lines
4.8 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { NextRequest } from 'next/server';
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
import path from 'node:path';
// Writes from the admin panel must answer 409 with the read-only message
// instead of a bare 500, and must not change anything on the way (#1249).
// Hoisted so the dirs are set before any admin module loads.
const configDir = await vi.hoisted(async () => {
const { mkdtempSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const dir = mkdtempSync(join(tmpdir(), 'bulwark-readonly-'));
process.env.ADMIN_CONFIG_DIR = dir;
process.env.ADMIN_STATE_DIR = join(dir, 'state');
return dir;
});
let readOnly = false;
vi.mock('@/lib/admin/session', () => ({
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
getClientIP: () => '127.0.0.1',
}));
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
vi.mock('@/lib/admin/paths', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/admin/paths')>();
return {
...actual,
isConfigReadOnly: () => readOnly,
assertWritable: (operation: string) => {
if (readOnly) throw new actual.ConfigReadOnlyError(operation);
},
};
});
import { PATCH as patchConfig, DELETE as deleteConfig } from '../config/route';
import { PUT as putPolicy } from '../policy/route';
import { DELETE as deleteBranding } from '../branding/route';
import { POST as changePassword } from '../change-password/route';
import { POST as decidePluginApproval } from '../plugin-approvals/route';
import { setInitialAdminPassword, verifyAdminPassword } from '@/lib/admin/password';
import { requestApproval, listApprovals } from '@/lib/admin/plugin-approvals';
import { configManager } from '@/lib/admin/config-manager';
function req(url: string, method: string, body: unknown) {
return new NextRequest(`http://localhost${url}`, {
method,
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
});
}
async function expectReadOnly(res: Response) {
expect(res.status).toBe(409);
expect((await res.json()).error).toMatch(/configuration is read-only/);
}
beforeAll(async () => {
await setInitialAdminPassword('old-password-123');
await requestApproval('demo-plugin', 'abcdef0123456789', { name: 'Demo', version: '1.0.0' } as never, 'user@example.org');
await configManager.ensureLoaded();
});
beforeEach(() => {
readOnly = true;
});
afterAll(() => {
rmSync(configDir, { recursive: true, force: true });
});
describe('admin writes on a read-only config (#1249)', () => {
it('PATCH /api/admin/config answers 409 with the reason', async () => {
await expectReadOnly(await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Changed' })));
expect(configManager.get('appName')).not.toBe('Changed');
});
it('DELETE /api/admin/config answers 409 with the reason', async () => {
await expectReadOnly(await deleteConfig(req('/api/admin/config', 'DELETE', { key: 'appName' })));
});
it('PUT /api/admin/policy answers 409 with the reason', async () => {
await expectReadOnly(await putPolicy(req('/api/admin/policy', 'PUT', { restrictions: {} })));
});
it('DELETE /api/admin/branding refuses before deleting the file', async () => {
const dir = path.join(configDir, 'branding');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'faviconUrl.png');
writeFileSync(file, 'png');
await expectReadOnly(await deleteBranding(req('/api/admin/branding', 'DELETE', { slot: 'faviconUrl' })));
expect(existsSync(file)).toBe(true);
});
it('POST /api/admin/change-password refuses and keeps the old password', async () => {
await expectReadOnly(await changePassword(req('/api/admin/change-password', 'POST', {
currentPassword: 'old-password-123',
newPassword: 'new-password-456',
})));
expect(await verifyAdminPassword('old-password-123')).toBe(true);
expect(await verifyAdminPassword('new-password-456')).toBe(false);
});
it('POST /api/admin/plugin-approvals refuses and leaves the request pending', async () => {
await expectReadOnly(await decidePluginApproval(req('/api/admin/plugin-approvals', 'POST', {
pluginId: 'demo-plugin',
bundleHash: 'abcdef0123456789',
decision: 'approved',
})));
expect((await listApprovals()).map(e => e.status)).toEqual(['pending']);
});
it('still saves when the config is writable', async () => {
readOnly = false;
const res = await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Writable' }));
expect(res.status).toBe(200);
expect(configManager.get('appName')).toBe('Writable');
});
});