Dev (extensions): add main-flow test coverage for the extension lifecycle - Cover install/load, activation in the worker isolation host, an API call allowed with its permission and denied without it, and teardown on disable/uninstall - Assert the host binds caller identity on every privileged IPC call - Add neutral regression cases for the realm-escape technique - Runs under vitest in CI on Linux, alongside the Rust command tests

This commit is contained in:
aleksey-hoffman committed 2026-10-18 13:52:50 +03:00
1 parent 4b0a241999
commit 5b6cab440d
2 files changed
+287

No files matched your search

@@ -0,0 +1,94 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import {
beforeEach, describe, expect, it, vi,
} from 'vitest';
import type { ExtensionPermission } from '@/types/extension';
import type { ExtensionContext } from '@/modules/extensions/api/extension-context';
const { invokeAsExtensionMock } = vi.hoisted(() => ({
invokeAsExtensionMock: vi.fn(),
}));
vi.mock('@/modules/extensions/runtime/extension-invoke', () => ({
invokeAsExtension: invokeAsExtensionMock,
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async () => () => {}),
}));
import { createShellAPI } from '@/modules/extensions/api/create-shell-api';
function createContext(permissions: ExtensionPermission[] = []): ExtensionContext {
return {
extensionId: 'test.extension',
hasPermission: (permission: ExtensionPermission) => permissions.includes(permission),
t: (key: string, params?: Record<string, string | number>) =>
params?.permission ? `${key}:${params.permission}` : key,
} as unknown as ExtensionContext;
}
describe('createShellAPI permission gating', () => {
beforeEach(() => {
invokeAsExtensionMock.mockReset();
});
it('runs a command when the shell permission is granted', async () => {
invokeAsExtensionMock.mockResolvedValue({
code: 0,
stdout: 'ok',
stderr: '',
});
const shell = createShellAPI(createContext(['shell']));
const result = await shell.run('yt-dlp', ['--version']);
expect(result).toEqual({
code: 0,
stdout: 'ok',
stderr: '',
});
expect(invokeAsExtensionMock).toHaveBeenCalledTimes(1);
// Identity is bound to the owning extension by the host, not chosen by the
// extension: invokeAsExtension injects callerExtensionId server-side.
expect(invokeAsExtensionMock).toHaveBeenCalledWith(
'test.extension',
'run_extension_command',
{
extensionId: 'test.extension',
commandPath: 'yt-dlp',
args: ['--version'],
},
);
});
it('denies run() when the shell permission is missing', async () => {
const shell = createShellAPI(createContext([]));
await expect(shell.run('yt-dlp', ['--version'])).rejects.toThrow(
'extensions.api.permissionDenied:shell',
);
expect(invokeAsExtensionMock).not.toHaveBeenCalled();
});
it('denies runWithProgress() when the shell permission is missing', async () => {
const shell = createShellAPI(createContext([]));
await expect(shell.runWithProgress('yt-dlp', [])).rejects.toThrow(
'extensions.api.permissionDenied:shell',
);
expect(invokeAsExtensionMock).not.toHaveBeenCalled();
});
it('denies renamePartFilesToTs() when the shell permission is missing', async () => {
const shell = createShellAPI(createContext([]));
await expect(shell.renamePartFilesToTs('/tmp')).rejects.toThrow(
'extensions.api.permissionDenied:shell',
);
expect(invokeAsExtensionMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
// End-to-end-ish coverage of the extension main flow at the layer that runs in
// CI on Linux (vitest + the Rust command tests). A full Tauri WebDriver e2e is
// not possible on this runner (WebKitGTK ships no WebDriver), so this exercises
// the real loader against a mocked Tauri IPC surface and the mocked worker host,
// which is the actual isolation boundary for `api` extensions.
//
// Covered: install/load of a local extension, activation, teardown
// (disable/uninstall), the host-bound caller identity on every privileged IPC
// call, and the realm-escape regression cases. The *real* validateExtensionCode
// is used here (the sandbox module is intentionally not mocked).
import {
afterEach, beforeEach, describe, expect, it, vi,
} from 'vitest';
import type { ApiExtensionManifest } from '@/types/extension';
const {
invokeMock,
createExtensionAPIMock,
registerExtensionConfigurationMock,
registerExtensionKeybindingsMock,
clearExtensionRegistrationsMock,
clearExtensionActivationRegistrationsMock,
initializeWorkerMock,
activateWorkerMock,
deactivateWorkerMock,
destroyWorkerMock,
createWorkerHostMock,
createObjectUrlMock,
revokeObjectUrlMock,
} = vi.hoisted(() => ({
invokeMock: vi.fn(),
createExtensionAPIMock: vi.fn(() => ({})),
registerExtensionConfigurationMock: vi.fn(),
registerExtensionKeybindingsMock: vi.fn(),
clearExtensionRegistrationsMock: vi.fn(),
clearExtensionActivationRegistrationsMock: vi.fn(),
initializeWorkerMock: vi.fn(),
activateWorkerMock: vi.fn(),
deactivateWorkerMock: vi.fn(),
destroyWorkerMock: vi.fn(),
createWorkerHostMock: vi.fn(),
createObjectUrlMock: vi.fn(),
revokeObjectUrlMock: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: invokeMock,
convertFileSrc: vi.fn((path: string) => `asset://${path}`),
}));
vi.mock('@/modules/extensions/api', () => ({
createExtensionAPI: createExtensionAPIMock,
registerExtensionConfiguration: registerExtensionConfigurationMock,
registerExtensionKeybindings: registerExtensionKeybindingsMock,
clearExtensionRegistrations: clearExtensionRegistrationsMock,
clearExtensionActivationRegistrations: clearExtensionActivationRegistrationsMock,
}));
vi.mock('@/modules/extensions/runtime/worker-runtime', () => ({
createWorkerHost: createWorkerHostMock,
}));
import { getLoadedRuntime, loadExtensionRuntime, unloadExtensionRuntime } from '@/modules/extensions/runtime/loader';
import * as runtimeIndex from '@/modules/extensions/runtime';
import { validateExtensionCode } from '@/modules/extensions/runtime/sandbox';
function createManifest(overrides: Partial<ApiExtensionManifest> = {}): ApiExtensionManifest {
return {
id: 'sample.extension',
name: 'Sample Extension',
version: '1.0.0',
repository: 'https://github.com/example/sample-extension',
license: 'MIT',
extensionType: 'api',
main: 'index.js',
permissions: ['commands'],
activationEvents: ['onStartup'],
engines: { sigmaFileManager: '>=2.0.0' },
...overrides,
};
}
function mockFilesystem(entrySource: string): void {
invokeMock.mockImplementation(async (command: string) => {
switch (command) {
case 'get_extension_path':
return '/extensions/sample.extension';
case 'get_extension_storage_path':
return '/storage/sample.extension';
case 'extension_path_exists':
return true;
case 'read_extension_file':
return Array.from(new TextEncoder().encode(entrySource));
default:
throw new Error(`Unexpected invoke command: ${command}`);
}
});
}
describe('extension main flow', () => {
beforeEach(() => {
invokeMock.mockReset();
createExtensionAPIMock.mockClear();
initializeWorkerMock.mockReset();
activateWorkerMock.mockReset();
deactivateWorkerMock.mockReset();
destroyWorkerMock.mockReset();
createWorkerHostMock.mockReset();
createWorkerHostMock.mockReturnValue({
initialize: initializeWorkerMock,
activate: activateWorkerMock,
deactivate: deactivateWorkerMock,
disposeActivationResources: vi.fn(),
destroy: destroyWorkerMock,
});
createObjectUrlMock.mockReset();
revokeObjectUrlMock.mockReset();
createObjectUrlMock.mockReturnValue('blob:sample.extension:module');
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: createObjectUrlMock,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: revokeObjectUrlMock,
});
});
afterEach(async () => {
await unloadExtensionRuntime('sample.extension');
});
it('installs, activates in the isolated worker, and binds caller identity on every IPC call', async () => {
mockFilesystem('export function activate() {}');
await loadExtensionRuntime('sample.extension', createManifest(), 'onStartup');
// api extensions execute inside the worker isolation host, never in the host realm.
expect(createWorkerHostMock).toHaveBeenCalledTimes(1);
expect(initializeWorkerMock).toHaveBeenCalledWith('blob:sample.extension:module');
expect(activateWorkerMock).toHaveBeenCalledWith({
extensionId: 'sample.extension',
extensionPath: '/extensions/sample.extension',
storagePath: '/storage/sample.extension',
activationEvent: 'onStartup',
});
// Every privileged IPC call carries callerExtensionId set to the owning
// extension; the extension cannot choose a different identity.
for (const [, args] of invokeMock.mock.calls) {
expect((args as { callerExtensionId?: string }).callerExtensionId).toBe('sample.extension');
}
});
it('tears down the worker on disable/uninstall', async () => {
mockFilesystem('export function activate() {}');
await loadExtensionRuntime('sample.extension', createManifest(), 'onStartup');
expect(getLoadedRuntime('sample.extension')).toBeDefined();
await unloadExtensionRuntime('sample.extension');
expect(deactivateWorkerMock).toHaveBeenCalled();
expect(destroyWorkerMock).toHaveBeenCalled();
expect(getLoadedRuntime('sample.extension')).toBeUndefined();
});
it('rejects an entry module that attempts a realm escape before it loads', async () => {
// Neutral regression for the computed member-access technique: the static
// screen rejects it, and the module never reaches the worker.
mockFilesystem('const C = ({})["constructor"]; export const x = C;');
await expect(
loadExtensionRuntime('sample.extension', createManifest(), 'onStartup'),
).rejects.toThrow('Extension code validation failed');
expect(initializeWorkerMock).not.toHaveBeenCalled();
expect(activateWorkerMock).not.toHaveBeenCalled();
expect(getLoadedRuntime('sample.extension')).toBeUndefined();
});
it('does not re-expose a shared-realm in-process sandbox factory', () => {
expect('createSandbox' in runtimeIndex).toBe(false);
expect('createSandboxedFunction' in runtimeIndex).toBe(false);
expect(validateExtensionCode('eval("x")').valid).toBe(false);
});
});