mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
Fixes from the review of the admin part: - GET /api/admin/policy reads the policy and its version together; an identity check waiting on the store let another replica's change pair the old policy with the new ETag, and a save with it then won. - A policy save answers with the stored policy and its ETag, and the policy, organisation, plugins and themes pages continue from that instead of reading it back from an instance that may not have it yet. - A stored policy that doesn't parse keeps its version, so a save can replace it instead of answering 409 forever. - A reload of another replica's change throws when this replica's writes kept landing while it read, so the change poller tries again; a write whose reload raced it reads the document again instead of applying its older result. - Dropping a deleted plugin from the policy rereads policy.json first in local mode, as every other write does. - New updateAdminConfig(key, change) builds a value on the stored one. The policy route's test mocks gain getPolicyWithVersion.
130 lines
5.4 KiB
TypeScript
130 lines
5.4 KiB
TypeScript
// @vitest-environment node
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
|
|
// Endpoints answering anyone used to hand out what fingerprints an instance:
|
|
// exact versions and pending advisories, memory figures, the installed
|
|
// plugins and the hosts they reach, push relays, sidebar app URLs and every
|
|
// served domain.
|
|
|
|
const gate = vi.hoisted(() => ({ signedIn: false }));
|
|
vi.mock('@/lib/auth/instance-details', () => ({
|
|
canSeeInstanceDetails: async () => gate.signedIn,
|
|
signedInIdentity: async () => null,
|
|
}));
|
|
|
|
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }));
|
|
const POLICY = {
|
|
restrictions: {},
|
|
features: {},
|
|
defaults: {},
|
|
themePolicy: { allowUserThemes: true },
|
|
forceEnabledPlugins: ['bulwark-gpg'],
|
|
approvedPlugins: ['quick-notes'],
|
|
forceEnabledThemes: [],
|
|
pushRelays: [{ label: 'Internal', url: 'https://relay.corp.example' }],
|
|
pushRelayUrl: 'https://relay.corp.example',
|
|
pushRelayUrlLocked: true,
|
|
defaultSidebarApps: [{ id: 'wiki', name: 'Wiki', url: 'https://wiki.corp.example' }],
|
|
};
|
|
vi.mock('@/lib/admin/config-manager', () => ({
|
|
configManager: {
|
|
ensureLoaded: async () => {},
|
|
get: (key: string, fallback: unknown) => (key in config.values ? config.values[key] : fallback),
|
|
getPolicy: () => POLICY,
|
|
getPolicyVersion: () => 'v1',
|
|
getPolicyWithVersion() { return { policy: this.getPolicy(), version: 'v1' }; },
|
|
},
|
|
}));
|
|
vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } }));
|
|
vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' }));
|
|
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() }));
|
|
|
|
const STATUS = {
|
|
schema: 1, current: '1.9.0', latest: '1.9.2', updateAvailable: true, severity: 'security',
|
|
url: 'https://example.org/release', advisory: 'Stored XSS in the reader, fixed in 1.9.2', checkedAt: '2026-09-26T00:00:00Z',
|
|
};
|
|
vi.mock('@/lib/version-check', () => ({
|
|
checkOnce: vi.fn(),
|
|
loadState: async () => ({ status: STATUS, lastCheckedAt: null, lastSuccessAt: null }),
|
|
freshStatus: (state: { status: unknown }) => state.status,
|
|
}));
|
|
vi.mock('@/lib/admin/plugin-registry', () => ({
|
|
getPluginRegistry: async () => ({ plugins: [] }),
|
|
getThemeRegistry: async () => ({ themes: [] }),
|
|
}));
|
|
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: async () => [] }));
|
|
|
|
import { GET as health } from '@/app/api/health/route';
|
|
import { GET as updateStatus } from '@/app/api/system/update-status/route';
|
|
import { GET as plugins } from '@/app/api/plugins/route';
|
|
import { GET as policy } from '@/app/api/admin/policy/route';
|
|
import { GET as connectorCapabilities } from '@/app/api/connector/capabilities/route';
|
|
|
|
const req = (path: string) => new NextRequest(`https://mail.example${path}`);
|
|
|
|
beforeEach(() => {
|
|
gate.signedIn = false;
|
|
config.values = {};
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('endpoints for visitors who are not signed in', () => {
|
|
it('health answers the liveness probe but no diagnostics', async () => {
|
|
const body = await (await health(req('/api/health?detailed=true'))).json();
|
|
expect(body.status).toBe('healthy');
|
|
expect(body).not.toHaveProperty('version');
|
|
expect(body).not.toHaveProperty('memory');
|
|
expect(body).not.toHaveProperty('nodeVersion');
|
|
|
|
gate.signedIn = true;
|
|
expect(await (await health(req('/api/health?detailed=true'))).json()).toHaveProperty('memory');
|
|
});
|
|
|
|
it('update status follows loginShowVersion and never carries the advisory text', async () => {
|
|
const shown = await (await updateStatus(req('/api/system/update-status'))).json();
|
|
expect(shown.status).toMatchObject({ current: '1.9.0', severity: 'security', advisory: null });
|
|
|
|
config.values.loginShowVersion = false;
|
|
expect((await (await updateStatus(req('/api/system/update-status'))).json()).status).toBeNull();
|
|
|
|
gate.signedIn = true;
|
|
expect((await (await updateStatus(req('/api/system/update-status'))).json()).status).toEqual(STATUS);
|
|
});
|
|
|
|
it('the connector probe follows loginShowVersion and still lists its targets', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.9.0');
|
|
expect((await (await connectorCapabilities()).json()).version).toBe('1.9.0');
|
|
|
|
config.values.loginShowVersion = false;
|
|
const hidden = await (await connectorCapabilities()).json();
|
|
expect(hidden.version).toBeNull();
|
|
expect(hidden.product).toBe('bulwark-webmail');
|
|
expect(hidden.targets.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('the plugin list needs a session', async () => {
|
|
expect((await plugins(req('/api/plugins'))).status).toBe(401);
|
|
gate.signedIn = true;
|
|
expect((await plugins(req('/api/plugins'))).status).toBe(200);
|
|
});
|
|
|
|
it('the policy keeps what the login page needs and drops the deployment map', async () => {
|
|
const res = await policy(req('/api/admin/policy'));
|
|
expect(res.headers.get('x-bulwark-policy-scope')).toBe('public');
|
|
const body = await res.json();
|
|
expect(body.themePolicy).toEqual(POLICY.themePolicy);
|
|
expect(body).toMatchObject({
|
|
forceEnabledPlugins: [], approvedPlugins: [], pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [],
|
|
});
|
|
|
|
gate.signedIn = true;
|
|
const full = await policy(req('/api/admin/policy'));
|
|
expect(full.headers.get('x-bulwark-policy-scope')).toBeNull();
|
|
expect(await full.json()).toEqual({ ...POLICY, scopes: [] });
|
|
});
|
|
});
|