mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
190 lines
9.2 KiB
TypeScript
190 lines
9.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { DEFAULT_RELAY_BASE_URL, resolveActiveRelayUrl, resolveDefaultRelayUrl, resolvePushRelayOptions } from '@/lib/push-relays';
|
|
import { resyncWebPush, revokePushDevice, type PushDevice } from '@/lib/web-push';
|
|
import { doGetPublicKeyFromWKD } from '@/lib/plugin-sandbox/host-api';
|
|
import { applyLiteConfig } from '@/lib/lite-config';
|
|
import { setNoThirdPartyTraffic, noThirdPartyTraffic, parseNoThirdPartyEnv } from '@/lib/third-party-mode';
|
|
import { isBuiltInHost, pluginAllowed, pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
|
|
import { isPlaceholderHost } from '@/lib/admin/outgoing-hosts-registry';
|
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
|
|
// The browser half of the "no third-party traffic" mode (#903).
|
|
|
|
const fetchSpy = vi.fn(async () => new Response('{}'));
|
|
|
|
beforeEach(() => {
|
|
fetchSpy.mockClear();
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
setNoThirdPartyTraffic(false);
|
|
});
|
|
|
|
const ownRelay = { label: 'Company', url: 'https://push.company.example' };
|
|
|
|
describe('push relays', () => {
|
|
it('drop the built-in relay in the mode and keep the admin\'s', () => {
|
|
setNoThirdPartyTraffic(true);
|
|
expect(resolvePushRelayOptions({})).toEqual([]);
|
|
expect(resolvePushRelayOptions({ pushRelays: [ownRelay] })).toEqual([ownRelay]);
|
|
setNoThirdPartyTraffic(false);
|
|
expect(resolvePushRelayOptions({ pushRelays: [ownRelay] }).map((o) => o.url)).toEqual([DEFAULT_RELAY_BASE_URL, ownRelay.url]);
|
|
});
|
|
|
|
it('fall back to the admin\'s relay, or to none, instead of the built-in one', () => {
|
|
setNoThirdPartyTraffic(true);
|
|
expect(resolveDefaultRelayUrl({ pushRelays: [ownRelay] })).toBe(ownRelay.url);
|
|
expect(resolveDefaultRelayUrl({})).toBe('');
|
|
// A user's saved pick of the built-in relay is not honoured either.
|
|
expect(resolveActiveRelayUrl({}, DEFAULT_RELAY_BASE_URL)).toBe('');
|
|
expect(resolveActiveRelayUrl({ pushRelays: [ownRelay] }, DEFAULT_RELAY_BASE_URL)).toBe(ownRelay.url);
|
|
// A configured pushRelayUrl pointing at the built-in host doesn't sneak it back.
|
|
expect(resolveDefaultRelayUrl({ pushRelayUrl: DEFAULT_RELAY_BASE_URL })).toBe('');
|
|
setNoThirdPartyTraffic(false);
|
|
expect(resolveDefaultRelayUrl({})).toBe(DEFAULT_RELAY_BASE_URL);
|
|
});
|
|
|
|
it('never get a request to the built-in relay in the mode', async () => {
|
|
const client = { destroyPushSubscription: vi.fn(async () => {}) } as unknown as IJMAPClient;
|
|
const device: PushDevice = { id: 'p1', deviceClientId: 'abc', expires: null, types: null, isThisDevice: false, relayStatus: 'unknown' };
|
|
|
|
setNoThirdPartyTraffic(true);
|
|
await revokePushDevice({ client, device, relayBaseUrl: DEFAULT_RELAY_BASE_URL });
|
|
expect(client.destroyPushSubscription).toHaveBeenCalledWith('p1');
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
|
|
setNoThirdPartyTraffic(false);
|
|
await revokePushDevice({ client, device, relayBaseUrl: DEFAULT_RELAY_BASE_URL });
|
|
expect(fetchSpy).toHaveBeenCalledWith(`${DEFAULT_RELAY_BASE_URL}/api/push/register/abc`, { method: 'DELETE' });
|
|
});
|
|
});
|
|
|
|
describe('push registrations made before the mode', () => {
|
|
it('are removed from the mail server when no relay of the organisation is left', async () => {
|
|
vi.stubGlobal('PushManager', class {});
|
|
vi.stubGlobal('Notification', { permission: 'granted' });
|
|
Object.defineProperty(navigator, 'serviceWorker', {
|
|
configurable: true,
|
|
value: { getRegistration: async () => undefined },
|
|
});
|
|
localStorage.setItem('bulwark.push.subscriptionId.v1.acc1', 'ps1');
|
|
const client = {
|
|
getAccountId: () => 'acc1',
|
|
destroyPushSubscription: vi.fn(async () => {}),
|
|
listPushSubscriptions: vi.fn(async () => []),
|
|
} as unknown as IJMAPClient;
|
|
try {
|
|
setNoThirdPartyTraffic(true);
|
|
await resyncWebPush({ client, relayBaseUrl: '', inboxOnly: false });
|
|
expect(client.destroyPushSubscription).toHaveBeenCalledWith('ps1');
|
|
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.acc1')).toBeNull();
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
delete (navigator as unknown as Record<string, unknown>).serviceWorker;
|
|
localStorage.clear();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('WKD key lookup', () => {
|
|
it('asks the recipient\'s domain only with the mode off', async () => {
|
|
setNoThirdPartyTraffic(true);
|
|
expect(await doGetPublicKeyFromWKD('joe@vendor.example')).toMatchObject({ status: 'ERROR' });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
|
|
setNoThirdPartyTraffic(false);
|
|
fetchSpy.mockImplementation(async () => new Response('', { status: 404 }));
|
|
await doGetPublicKeyFromWKD('joe@vendor.example');
|
|
expect(String((fetchSpy.mock.calls[0] as unknown[])[0])).toMatch(/^https:\/\/openpgpkey\.vendor\.example\//);
|
|
});
|
|
});
|
|
|
|
describe('plugins', () => {
|
|
it('that declare network access are refused in the mode, the rest run', () => {
|
|
const offline = { permissions: ['email:read', 'http:post'] };
|
|
const fetcher = { permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] };
|
|
const proxied = { permissions: ['http:proxy'] };
|
|
const framer = { permissions: [], frameOrigins: ['https://www.youtube-nocookie.com'] };
|
|
expect([offline, fetcher, proxied, framer].map(pluginDeclaresNetwork)).toEqual([false, true, true, true]);
|
|
|
|
setNoThirdPartyTraffic(true);
|
|
expect([offline, fetcher, proxied, framer].map(pluginAllowed)).toEqual([true, false, false, false]);
|
|
setNoThirdPartyTraffic(false);
|
|
expect([offline, fetcher, proxied, framer].map(pluginAllowed)).toEqual([true, true, true, true]);
|
|
});
|
|
});
|
|
|
|
describe('config', () => {
|
|
it('reads noThirdPartyTraffic from the Lite config.json', () => {
|
|
expect(applyLiteConfig({}).noThirdPartyTraffic).toBe(false);
|
|
expect(applyLiteConfig({ noThirdPartyTraffic: true }).noThirdPartyTraffic).toBe(true);
|
|
// A string as in env counts too (#903 review).
|
|
expect(applyLiteConfig({ noThirdPartyTraffic: 'yes' }).noThirdPartyTraffic).toBe(true);
|
|
expect(applyLiteConfig({ noThirdPartyTraffic: 'off' }).noThirdPartyTraffic).toBe(false);
|
|
});
|
|
|
|
it('turns the browser switches on once the runtime config is fetched', async () => {
|
|
vi.resetModules();
|
|
const mode = await import('@/lib/third-party-mode');
|
|
const { createConfig } = await import('./fixtures/config');
|
|
fetchSpy.mockImplementation(async () => Response.json(createConfig({ noThirdPartyTraffic: true })));
|
|
const { fetchConfig } = await import('@/hooks/use-config');
|
|
await fetchConfig();
|
|
expect(mode.noThirdPartyTraffic()).toBe(true);
|
|
});
|
|
|
|
it('parses the env spellings', () => {
|
|
expect(['true', 'TRUE', '1', 'yes', 'on', ' on '].map(parseNoThirdPartyEnv)).toEqual([true, true, true, true, true, true]);
|
|
expect(['false', '0', '', 'off', 'nope', undefined].map(parseNoThirdPartyEnv)).toEqual([false, false, false, false, false, false]);
|
|
expect(noThirdPartyTraffic()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('host helpers', () => {
|
|
it('know the built-in hosts and the placeholders', () => {
|
|
expect(isBuiltInHost('https://notifications.relay.bulwarkmail.org/')).toBe(true);
|
|
expect(isBuiltInHost('https://extensions.bulwarkmail.org')).toBe(true);
|
|
expect(isBuiltInHost('https://extensions.corp.example')).toBe(false);
|
|
expect(isBuiltInHost('not a url')).toBe(false);
|
|
expect(['mail.example.com', 'x', 'stalwart.internal:8080', '10.0.0.5', '*.vimeo.com', 'acme.test'].every(isPlaceholderHost)).toBe(true);
|
|
expect(['github.com', 'icons.duckduckgo.com', 'api.example.io'].some(isPlaceholderHost)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('before the config is known (#903 review)', () => {
|
|
it('treats the mode as on', async () => {
|
|
vi.resetModules();
|
|
const mode = await import('@/lib/third-party-mode');
|
|
const hosts = await import('@/lib/outgoing-hosts');
|
|
expect(mode.thirdPartyModeState()).toBe('unknown');
|
|
expect(mode.noThirdPartyTraffic()).toBe(true);
|
|
expect(hosts.relayAllowed('https://notifications.relay.bulwarkmail.org')).toBe(false);
|
|
expect(hosts.senderFaviconsAllowed()).toBe(false);
|
|
expect(hosts.mapsLinksAllowed()).toBe(false);
|
|
expect(hosts.pluginAllowed({ permissions: ['http:fetch'] })).toBe(false);
|
|
mode.setNoThirdPartyTraffic(false);
|
|
expect(hosts.relayAllowed('https://notifications.relay.bulwarkmail.org')).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['missing', async () => new Response('', { status: 404 })],
|
|
['not JSON', async () => new Response('{ this is not json', { status: 200 })],
|
|
])('turns the mode on in Lite when config.json is %s', async (_label, answer) => {
|
|
vi.resetModules();
|
|
vi.doMock('@/lib/lite', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/lib/lite')>()), IS_LITE: true, IS_LITE_STALWART: false }));
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
fetchSpy.mockImplementation(answer as never);
|
|
try {
|
|
const mode = await import('@/lib/third-party-mode');
|
|
const { fetchConfig } = await import('@/hooks/use-config');
|
|
expect((await fetchConfig()).noThirdPartyTraffic).toBe(true);
|
|
expect(mode.thirdPartyModeState()).toBe('on');
|
|
expect(String(warn.mock.calls[0]?.[0])).toMatch(/no third-party traffic/);
|
|
} finally {
|
|
vi.doUnmock('@/lib/lite');
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|