mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
Several endpoints answered anyone with what fingerprints a deployment: `/api/health?detailed=true` (versions, memory), the update status (exact version and the text of a pending security advisory, even with `loginShowVersion` off), `/api/plugins` (installed plugins and the hosts they may reach), the admin policy (push relays, sidebar app URLs, plugin lists) and `/api/config` (every domain each server serves). Visitors without a session now get: - the plain liveness answer from `/api/health`; - the update status only where the login page shows the version, and never the advisory text; - a 401 from `/api/plugins`, which the client only syncs after signing in; - the policy without plugin lists, push relays and sidebar apps, marked `X-Bulwark-Policy-Scope: public`; the client fetches the full policy once it signs in; - server domain lists only when servers are picked by domain. A mailbox session, a remembered session cookie or the admin session counts as signed in. Deployments without a session secret cannot tell sessions apart and keep the old answers.
53 lines
2.2 KiB
TypeScript
53 lines
2.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Before signing in the server answers with the public part of the policy;
|
|
// the store has to fetch the rest once a session exists.
|
|
|
|
const fetchMock = vi.hoisted(() => vi.fn());
|
|
vi.mock('@/lib/browser-navigation', () => ({ apiFetch: fetchMock }));
|
|
|
|
import { usePolicyStore } from '@/stores/policy-store';
|
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
|
|
|
function answer(policy: object, scope?: string) {
|
|
return new Response(JSON.stringify(policy), {
|
|
headers: scope ? { 'X-Bulwark-Policy-Scope': scope } : {},
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fetchMock.mockReset();
|
|
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false, partial: false });
|
|
});
|
|
|
|
describe('policy store', () => {
|
|
it('fetches the full policy after signing in when only the public part was loaded', async () => {
|
|
fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public'));
|
|
await usePolicyStore.getState().fetchPolicy();
|
|
expect(usePolicyStore.getState().partial).toBe(true);
|
|
|
|
const apps = [{ id: 'wiki', name: 'Wiki', url: 'https://wiki.example' }];
|
|
fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY, defaultSidebarApps: apps }));
|
|
await usePolicyStore.getState().refreshIfPartial();
|
|
expect(usePolicyStore.getState().partial).toBe(false);
|
|
expect(usePolicyStore.getState().policy.defaultSidebarApps).toEqual(apps);
|
|
|
|
await usePolicyStore.getState().refreshIfPartial();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('lets the later fetch win when the public answer arrives last', async () => {
|
|
let releasePublic!: (r: Response) => void;
|
|
fetchMock.mockImplementationOnce(() => new Promise<Response>((resolve) => { releasePublic = resolve; }));
|
|
const early = usePolicyStore.getState().fetchPolicy();
|
|
|
|
fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY, approvedPlugins: ['quick-notes'] }));
|
|
await usePolicyStore.getState().refreshIfPartial();
|
|
|
|
releasePublic(answer({ ...DEFAULT_POLICY }, 'public'));
|
|
await early;
|
|
expect(usePolicyStore.getState().partial).toBe(false);
|
|
expect(usePolicyStore.getState().policy.approvedPlugins).toEqual(['quick-notes']);
|
|
});
|
|
});
|