mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
115 lines
5.3 KiB
TypeScript
115 lines
5.3 KiB
TypeScript
/**
|
|
* api.keystore in the sandbox runtime. A PIN prompt can take minutes, so the facade
|
|
* must not give up after the RPC's usual 30 seconds, and errors have to reach the
|
|
* plugin as the bare code.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import React from 'react';
|
|
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
|
|
vi.mock('@/stores/toast-store', () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
|
}));
|
|
|
|
const HOST_ORIGIN = window.location.origin;
|
|
|
|
function post(source: unknown, data: unknown): void {
|
|
const ev = new MessageEvent('message', { data, origin: HOST_ORIGIN });
|
|
Object.defineProperty(ev, 'source', { value: source });
|
|
window.dispatchEvent(ev);
|
|
}
|
|
|
|
interface KeystoreFacade {
|
|
list(): Promise<unknown>;
|
|
sign(id: string, digest: Uint8Array, scheme: unknown): Promise<Uint8Array>;
|
|
decrypt(id: string, data: Uint8Array, scheme: unknown): Promise<Uint8Array>;
|
|
agree(id: string, peer: Uint8Array): Promise<Uint8Array>;
|
|
}
|
|
|
|
describe('api.keystore in the sandbox', () => {
|
|
let hostWindow: { postMessage: ReturnType<typeof vi.fn> };
|
|
const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
|
|
const g = globalThis as { __pluginApi?: { keystore: KeystoreFacade; host: { features: Record<string, boolean> } } };
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
hostWindow = { postMessage: vi.fn() };
|
|
Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window });
|
|
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
delete g.__pluginApi;
|
|
if (realParent) Object.defineProperty(window, 'parent', realParent);
|
|
});
|
|
|
|
const requests = () =>
|
|
hostWindow.postMessage.mock.calls.map((call) => call[0] as { type: string; id: string; method: string; args: unknown[] }).filter((message) => message.type === 'api-request');
|
|
|
|
async function boot(features: Record<string, boolean>): Promise<void> {
|
|
const bundle = 'module.exports = { activate: function (api) { globalThis.__pluginApi = api; } };';
|
|
const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime');
|
|
render(React.createElement(SandboxRuntime));
|
|
post(hostWindow, {
|
|
type: 'init',
|
|
payload: {
|
|
mode: 'background', pluginId: 'smime', tier: 'privileged', locale: 'en',
|
|
manifest: { id: 'smime', version: '1.0.0', permissions: ['crypto:full', 'keystore'], settings: {} },
|
|
code: bundle,
|
|
hostFeatures: { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false, ...features },
|
|
},
|
|
});
|
|
await waitFor(() => expect(g.__pluginApi).toBeDefined());
|
|
}
|
|
|
|
it('the flag reaches the plugin, and is not there when the host does not send it', async () => {
|
|
await boot({ keystore: true });
|
|
expect(g.__pluginApi!.host.features.keystore).toBe(true);
|
|
post(hostWindow, { type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false } });
|
|
expect(g.__pluginApi!.host.features.keystore).toBeFalsy();
|
|
}, 30_000);
|
|
|
|
it('a list has the ordinary RPC timeout: it never waits on a card', async () => {
|
|
await boot({ keystore: true });
|
|
vi.useFakeTimers();
|
|
const listing = g.__pluginApi!.keystore.list();
|
|
const outcome = listing.then(() => 'answered', (error: Error) => error.message);
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
await expect(outcome).resolves.toMatch(/timed out/);
|
|
}, 30_000);
|
|
|
|
it('no key operation gives up while the host has not answered: a PIN can take minutes', async () => {
|
|
await boot({ keystore: true });
|
|
vi.useFakeTimers();
|
|
const api = g.__pluginApi!.keystore;
|
|
const digest = new Uint8Array(32);
|
|
const settled: string[] = [];
|
|
const watch = (name: string, promise: Promise<unknown>) => promise.then(() => settled.push(name), () => settled.push(`${name} rejected`));
|
|
watch('sign', api.sign('id', digest, { scheme: 'rsa-pkcs1', hash: 'SHA-256' }));
|
|
watch('decrypt', api.decrypt('id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }));
|
|
watch('agree', api.agree('id', new Uint8Array(91)));
|
|
// Past the RPC's 30 s and past the network calls' 120 s.
|
|
await vi.advanceTimersByTimeAsync(600_000);
|
|
expect(settled).toEqual([]);
|
|
expect(requests().map((request) => request.method)).toEqual(['keystore.sign', 'keystore.decrypt', 'keystore.agree']);
|
|
}, 30_000);
|
|
|
|
it('passes the bytes as bytes, and hands an error back with the code as its message', async () => {
|
|
await boot({ keystore: true });
|
|
const api = g.__pluginApi!.keystore;
|
|
const digest = new Uint8Array(32).fill(9);
|
|
const scheme = { scheme: 'rsa-pss', hash: 'SHA-256' };
|
|
const signing = api.sign('the-id', digest, scheme);
|
|
const sent = requests().at(-1)!;
|
|
expect(sent.args).toEqual(['the-id', digest, scheme]);
|
|
post(hostWindow, { type: 'api-response', id: sent.id, ok: false, error: 'keystore:cancelled' });
|
|
await expect(signing).rejects.toThrow(/^keystore:cancelled$/);
|
|
|
|
const decrypting = api.decrypt('the-id', new Uint8Array(256), { scheme: 'rsa-pkcs1' });
|
|
post(hostWindow, { type: 'api-response', id: requests().at(-1)!.id, ok: true, result: new Uint8Array([1, 2]) });
|
|
await expect(decrypting).resolves.toEqual(new Uint8Array([1, 2]));
|
|
}, 30_000);
|
|
});
|