mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: keep the organisation's locks when the policy can't be fetched
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { debug } from '@/lib/debug';
|
||||
import { whenPolicySettled } from '@/stores/policy-store';
|
||||
|
||||
// 'plugin-forward': a plugin forwarding a message (api.email.forward).
|
||||
export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward';
|
||||
@@ -130,6 +131,9 @@ export function finalizeOutgoing(
|
||||
* one must not make sending impossible.
|
||||
*/
|
||||
export async function runPreSendChecks(context: OutgoingContext): Promise<boolean> {
|
||||
// The checks and what the composer adds after them (labels, signatures)
|
||||
// follow the organisation policy: wait for its first fetch.
|
||||
await whenPolicySettled();
|
||||
for (const entry of [...checks]) {
|
||||
try {
|
||||
if (!(await entry.run(context))) {
|
||||
|
||||
@@ -26,6 +26,9 @@ const SIGNED_OUT_STORAGE_KEYS = [
|
||||
'files-path-stack',
|
||||
// Names of the people the user gave access to (Settings > Sharing).
|
||||
'bulwark-delegate-names',
|
||||
// The last organisation policy (stores/policy-store.ts POLICY_CACHE_KEY):
|
||||
// its scopes, labels and notices are the signed-in user's.
|
||||
'bulwark-policy-last',
|
||||
];
|
||||
|
||||
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A failed policy fetch must not lift the organisation's locks: a refetch
|
||||
// keeps what the server sent, and a fresh start without the server uses the
|
||||
// last policy this browser saw instead of the defaults.
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/browser-navigation', () => ({ apiFetch: fetchMock }));
|
||||
|
||||
import { POLICY_CACHE_KEY, usePolicyStore, whenPolicySettled } from '@/stores/policy-store';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
const LOCKED = {
|
||||
...DEFAULT_POLICY,
|
||||
restrictions: { externalContentPolicy: { locked: true, value: 'block' } },
|
||||
};
|
||||
|
||||
const ok = (policy: object) => new Response(JSON.stringify(policy));
|
||||
const locked = () => usePolicyStore.getState().isSettingLocked('externalContentPolicy');
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
localStorage.clear();
|
||||
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, rawPolicy: { ...DEFAULT_POLICY }, loaded: false, fetching: false, source: 'none', partial: false });
|
||||
});
|
||||
|
||||
describe('policy fetch failures', () => {
|
||||
it('keeps the server policy when a later fetch fails', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(LOCKED));
|
||||
await usePolicyStore.getState().fetchPolicy();
|
||||
expect(locked()).toBe(true);
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
await usePolicyStore.getState().fetchPolicy();
|
||||
expect(locked()).toBe(true);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('oops', { status: 502 }));
|
||||
await usePolicyStore.getState().fetchPolicy();
|
||||
expect(locked()).toBe(true);
|
||||
expect(usePolicyStore.getState().source).toBe('server');
|
||||
});
|
||||
|
||||
it('starts from the last policy this browser saw when the server is out of reach', async () => {
|
||||
fetchMock.mockResolvedValueOnce(ok(LOCKED));
|
||||
await usePolicyStore.getState().fetchPolicy();
|
||||
expect(localStorage.getItem(POLICY_CACHE_KEY)).toContain('externalContentPolicy');
|
||||
|
||||
// A new page load: nothing in memory, no network.
|
||||
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, rawPolicy: { ...DEFAULT_POLICY }, loaded: false, source: 'none' });
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
await usePolicyStore.getState().fetchPolicy();
|
||||
expect(usePolicyStore.getState().source).toBe('cache');
|
||||
expect(locked()).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the defaults only when no policy was ever fetched', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
await usePolicyStore.getState().fetchPolicy();
|
||||
expect(usePolicyStore.getState().source).toBe('fallback');
|
||||
expect(usePolicyStore.getState().loaded).toBe(true);
|
||||
expect(locked()).toBe(false);
|
||||
});
|
||||
|
||||
it("doesn't keep the public part a signed-out page gets", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(DEFAULT_POLICY), { headers: { 'X-Bulwark-Policy-Scope': 'public' } }));
|
||||
await usePolicyStore.getState().fetchPolicy();
|
||||
expect(localStorage.getItem(POLICY_CACHE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('whenPolicySettled', () => {
|
||||
it('waits for the first fetch, then resolves', async () => {
|
||||
let release!: (r: Response) => void;
|
||||
fetchMock.mockReturnValueOnce(new Promise<Response>((resolve) => { release = resolve; }));
|
||||
const fetching = usePolicyStore.getState().fetchPolicy();
|
||||
let settled = false;
|
||||
const wait = whenPolicySettled(5000).then(() => { settled = true; });
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
release(ok(LOCKED));
|
||||
await fetching;
|
||||
await wait;
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it('gives up after its timeout', async () => {
|
||||
fetchMock.mockReturnValueOnce(new Promise<Response>(() => {}));
|
||||
void usePolicyStore.getState().fetchPolicy();
|
||||
const started = Date.now();
|
||||
await whenPolicySettled(50);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
+80
-3
@@ -35,6 +35,14 @@ interface PolicyState {
|
||||
* things the policy may lock fail closed (hooks/use-third-party-mode.ts).
|
||||
*/
|
||||
fetching: boolean;
|
||||
/**
|
||||
* Where `rawPolicy` came from: the server, the copy of the last fetched
|
||||
* policy kept in this browser (the fetch failed), or the built-in
|
||||
* defaults (nothing was ever fetched). A loaded server policy is never
|
||||
* replaced by the copy or the defaults, so a network blip can't lift the
|
||||
* organisation's locks.
|
||||
*/
|
||||
source: 'none' | 'server' | 'cache' | 'fallback';
|
||||
/**
|
||||
* The server answered with the public subset (not signed in yet); the
|
||||
* full policy is fetched again once a session exists.
|
||||
@@ -78,6 +86,55 @@ interface PolicyState {
|
||||
|
||||
let policyRequest = 0;
|
||||
|
||||
/**
|
||||
* The last full policy the server sent, kept so a failed fetch (network
|
||||
* down, a refetch that errors) doesn't fall back to the defaults and lift
|
||||
* every lock. Removed at full sign-out (lib/sign-out-cleanup.ts).
|
||||
*/
|
||||
export const POLICY_CACHE_KEY = 'bulwark-policy-last';
|
||||
|
||||
function readCachedPolicy(): Record<string, unknown> | null {
|
||||
try {
|
||||
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(POLICY_CACHE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedPolicy(data: unknown): void {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
if (data && typeof data === 'object') localStorage.setItem(POLICY_CACHE_KEY, JSON.stringify(data));
|
||||
else localStorage.removeItem(POLICY_CACHE_KEY);
|
||||
} catch {
|
||||
// Storage full or blocked: the in-memory policy still applies.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the first policy fetch has settled (or after `timeoutMs`).
|
||||
* Sending waits for it, so a required label or a send check the policy
|
||||
* turns on can't be skipped by sending in the first moment after load.
|
||||
*/
|
||||
export async function whenPolicySettled(timeoutMs = 5000): Promise<void> {
|
||||
const settled = () => {
|
||||
const { fetching, loaded } = usePolicyStore.getState();
|
||||
return loaded || !fetching;
|
||||
};
|
||||
if (settled()) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => { unsubscribe(); resolve(); }, timeoutMs);
|
||||
const unsubscribe = usePolicyStore.subscribe(() => {
|
||||
if (!settled()) return;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const COLOR_MODES: readonly ColorMode[] = ['light', 'dark', 'system'];
|
||||
|
||||
const sameSubject = (a: PolicySubject | null, b: PolicySubject | null) =>
|
||||
@@ -96,6 +153,7 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||
scopeDomains: null,
|
||||
loaded: false,
|
||||
fetching: false,
|
||||
source: 'none',
|
||||
partial: false,
|
||||
demoFeatureOverrides: {},
|
||||
|
||||
@@ -110,6 +168,20 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||
// for the admin server; missing means defaults, and the gates that need
|
||||
// the server stay pinned off either way (lib/lite-config.ts).
|
||||
const fallback = IS_LITE ? { policy: applyLitePolicy({}) } : {};
|
||||
// The fetch failed: keep a policy the server already gave, else the last
|
||||
// one this browser saw, else the defaults.
|
||||
const settleWithoutServer = () => {
|
||||
if (get().source === 'server') {
|
||||
set({ loaded: true });
|
||||
return;
|
||||
}
|
||||
const cached = readCachedPolicy();
|
||||
if (cached) {
|
||||
set({ ...resolved(IS_LITE ? applyLitePolicy(cached) : (cached as unknown as SettingsPolicy), get().subject), loaded: true, source: 'cache' });
|
||||
} else {
|
||||
set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true, source: 'fallback' });
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Stalwart serves bundle files immutably: a build-id URL instead of no-store.
|
||||
const res = IS_LITE_STALWART
|
||||
@@ -122,12 +194,17 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||
const partial = res.headers?.get?.(POLICY_SCOPE_HEADER) === 'public';
|
||||
const domainsHeader = res.headers?.get?.(POLICY_DOMAINS_HEADER);
|
||||
const scopeDomains = typeof domainsHeader === 'string' ? domainsHeader.split(',').filter(Boolean) : null;
|
||||
set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, partial, scopeDomains });
|
||||
set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, source: 'server', partial, scopeDomains });
|
||||
if (!partial) writeCachedPolicy(data);
|
||||
} else if (IS_LITE && res.status === 404) {
|
||||
// No policy.json: the deployment set nothing, which is an answer.
|
||||
set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true, source: 'server' });
|
||||
writeCachedPolicy(null);
|
||||
} else {
|
||||
set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true });
|
||||
settleWithoutServer();
|
||||
}
|
||||
} catch {
|
||||
if (request === policyRequest) set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true });
|
||||
if (request === policyRequest) settleWithoutServer();
|
||||
} finally {
|
||||
if (request === policyRequest) set({ fetching: false });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user