mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import type { DragEvent } from 'react';
|
|
|
|
import { adminApi } from '../../../api/client';
|
|
import type { PluginRow, RegistryItem } from './pluginModel';
|
|
import { usePluginsAdmin } from './usePluginsAdmin';
|
|
|
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
|
const loadPlugins = vi.hoisted(() => vi.fn(async () => {}));
|
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
|
vi.mock('../../../i18n', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
|
|
}),
|
|
}));
|
|
vi.mock('../../../store/pluginStore', () => ({ usePluginStore: { getState: () => ({ loadPlugins }) } }));
|
|
|
|
function row(over: Partial<PluginRow> = {}): PluginRow {
|
|
return {
|
|
id: 'alpha',
|
|
name: 'Alpha',
|
|
description: null,
|
|
type: 'widget',
|
|
icon: null,
|
|
version: '1.0.0',
|
|
status: 'active',
|
|
enabled: 1,
|
|
last_error: null,
|
|
reviewed_at: null,
|
|
source_repo: 'org/alpha',
|
|
permissions: '[]',
|
|
capabilities: '{}',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function entry(over: Partial<RegistryItem> = {}): RegistryItem {
|
|
return {
|
|
id: 'alpha',
|
|
name: 'Alpha',
|
|
author: 'someone',
|
|
description: 'does things',
|
|
repo: 'org/alpha',
|
|
type: 'widget',
|
|
latest: '1.1.0',
|
|
latestCompatible: '1.1.0',
|
|
minTrekVersion: null,
|
|
reviewedAt: null,
|
|
screenshotUrl: null,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function serve(plugins: PluginRow[], registry: RegistryItem[] = []) {
|
|
vi.spyOn(adminApi, 'plugins').mockResolvedValue({ enabled: true, devLink: true, plugins });
|
|
vi.spyOn(adminApi, 'pluginBrowse').mockResolvedValue(registry);
|
|
}
|
|
|
|
/** A promise this test controls the settling of. */
|
|
function deferred<T>() {
|
|
let resolve!: (v: T) => void;
|
|
const promise = new Promise<T>((r) => {
|
|
resolve = r;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function mounted() {
|
|
const hook = renderHook(() => usePluginsAdmin());
|
|
await waitFor(() => expect(hook.result.current.loading).toBe(false));
|
|
return hook;
|
|
}
|
|
|
|
function dragEvent(types: string[]): DragEvent {
|
|
return { dataTransfer: { types, files: [] }, preventDefault: vi.fn() } as unknown as DragEvent;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
toast.success.mockReset();
|
|
toast.error.mockReset();
|
|
loadPlugins.mockClear();
|
|
});
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
describe('usePluginsAdmin', () => {
|
|
it('FE-ADMIN-PLUGINS-HOOK-001: loads the plugins, syncs the app store and indexes the registry', async () => {
|
|
serve([row()], [entry()]);
|
|
const { result } = await mounted();
|
|
expect(loadPlugins).toHaveBeenCalled();
|
|
expect(result.current.runtimeOn).toBe(true);
|
|
expect(result.current.devLink).toBe(true);
|
|
expect(result.current.ready).toBe(true);
|
|
await waitFor(() => expect(result.current.latest).toEqual({ alpha: '1.1.0' }));
|
|
expect(result.current.regById.alpha?.name).toBe('Alpha');
|
|
expect(result.current.updatable.map((p) => p.id)).toEqual(['alpha']);
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-002: a failed load leaves the panel in its error state', async () => {
|
|
vi.spyOn(adminApi, 'plugins').mockRejectedValue(new Error('down'));
|
|
const { result } = await mounted();
|
|
expect(result.current.error).toBe(true);
|
|
expect(result.current.ready).toBe(false);
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-003: switching tabs snaps a sort the tab cannot offer back to name', async () => {
|
|
serve([row()]);
|
|
const { result } = await mounted();
|
|
act(() => result.current.setSort('updates'));
|
|
act(() => result.current.setView('discover'));
|
|
expect(result.current.sort).toBe('name');
|
|
act(() => result.current.setSort('downloads'));
|
|
act(() => result.current.setView('installed'));
|
|
expect(result.current.sort).toBe('name');
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-004: disabling a plugin deactivates it, says so and reloads the list', async () => {
|
|
serve([row()]);
|
|
vi.spyOn(adminApi, 'pluginDeactivate').mockResolvedValue({});
|
|
const { result } = await mounted();
|
|
await act(async () => {
|
|
result.current.toggle(result.current.plugins[0]!);
|
|
});
|
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('admin.plugins.deactivated'));
|
|
expect(adminApi.pluginDeactivate).toHaveBeenCalledWith('alpha');
|
|
await waitFor(() => expect(adminApi.plugins).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-005: an activation needing consent queues it, and approving activates with consent', async () => {
|
|
serve([row({ enabled: 0, signed: false })]);
|
|
vi.spyOn(adminApi, 'pluginActivate')
|
|
.mockRejectedValueOnce({
|
|
response: { status: 409, data: { code: 'CONSENT_REQUIRED', newPermissions: ['db:own'], newEgress: [] } },
|
|
})
|
|
.mockResolvedValue({});
|
|
const { result } = await mounted();
|
|
await act(async () => {
|
|
result.current.toggle(result.current.plugins[0]!);
|
|
});
|
|
await waitFor(() => expect(result.current.consentQueue).toHaveLength(1));
|
|
expect(result.current.consentQueue[0]).toMatchObject({ version: '1.0.0', newPermissions: ['db:own'] });
|
|
expect(result.current.consentUnsigned).toBe(true);
|
|
await act(async () => {
|
|
await result.current.approveConsent();
|
|
});
|
|
expect(adminApi.pluginActivate).toHaveBeenLastCalledWith('alpha', true);
|
|
expect(result.current.consentQueue).toHaveLength(0);
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-006: deferring consent drops it from the queue and says the plugin stays off', async () => {
|
|
serve([row({ enabled: 0 })]);
|
|
vi.spyOn(adminApi, 'pluginActivate').mockRejectedValue({
|
|
response: { status: 409, data: { code: 'CONSENT_REQUIRED', newPermissions: [], newEgress: ['x.example'] } },
|
|
});
|
|
const { result } = await mounted();
|
|
await act(async () => {
|
|
result.current.toggle(result.current.plugins[0]!);
|
|
});
|
|
await waitFor(() => expect(result.current.consentQueue).toHaveLength(1));
|
|
act(() => result.current.deferConsent());
|
|
expect(result.current.consentQueue).toHaveLength(0);
|
|
expect(toast.success).toHaveBeenCalledWith('admin.plugins.updateKeptOff');
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-007: a signature refusal opens the dialog, named from the registry for a new install', async () => {
|
|
serve([], [entry({ id: 'beta', name: 'Beta' })]);
|
|
vi.spyOn(adminApi, 'pluginInstall').mockRejectedValue({
|
|
response: { data: { code: 'SIGNATURE_INVALID', error: 'bad signature' } },
|
|
});
|
|
const { result } = await mounted();
|
|
await act(async () => {
|
|
result.current.openDiscover();
|
|
});
|
|
await waitFor(() => expect(result.current.regById.beta).toBeDefined());
|
|
await act(async () => {
|
|
result.current.install('beta');
|
|
});
|
|
await waitFor(() => expect(result.current.signatureBlock).not.toBeNull());
|
|
expect(result.current.signatureBlock).toEqual({
|
|
subject: { id: 'beta', name: 'Beta', keyFingerprint: null },
|
|
code: 'SIGNATURE_INVALID',
|
|
detail: 'bad signature',
|
|
});
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-008: Update all runs the updates one after another', async () => {
|
|
serve([row({ id: 'a', name: 'A' }), row({ id: 'b', name: 'B' })], [entry({ id: 'a' }), entry({ id: 'b' })]);
|
|
const first = deferred<unknown>();
|
|
const update = vi
|
|
.spyOn(adminApi, 'pluginUpdate')
|
|
.mockReturnValueOnce(first.promise)
|
|
.mockResolvedValue({ version: '1.1.0', activated: true, newPermissions: [], newEgress: [] });
|
|
const { result } = await mounted();
|
|
await waitFor(() => expect(result.current.updatable).toHaveLength(2));
|
|
let done!: Promise<void>;
|
|
act(() => {
|
|
done = result.current.updateAll();
|
|
});
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
await act(async () => {
|
|
first.resolve({ version: '1.1.0', activated: true, newPermissions: [], newEgress: [] });
|
|
await done;
|
|
});
|
|
expect(update).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-009: Update all starts only the first update, without a version', async () => {
|
|
serve([row({ id: 'a', name: 'A' }), row({ id: 'b', name: 'B' })], [entry({ id: 'a' }), entry({ id: 'b' })]);
|
|
const update = vi.spyOn(adminApi, 'pluginUpdate').mockReturnValue(new Promise(() => {}));
|
|
const { result } = await mounted();
|
|
await waitFor(() => expect(result.current.updatable).toHaveLength(2));
|
|
act(() => {
|
|
void result.current.updateAll();
|
|
});
|
|
expect(update.mock.calls).toEqual([['a', undefined]]);
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-010: the drop overlay arms on a file drag and ignores an unmatched leave', async () => {
|
|
serve([row()]);
|
|
const { result } = await mounted();
|
|
act(() => result.current.onDragLeave());
|
|
expect(result.current.dragActive).toBe(false);
|
|
act(() => result.current.onDragEnter(dragEvent(['text/plain'])));
|
|
expect(result.current.dragActive).toBe(false);
|
|
act(() => result.current.onDragEnter(dragEvent(['Files'])));
|
|
act(() => result.current.onDragEnter(dragEvent(['Files'])));
|
|
act(() => result.current.onDragLeave());
|
|
expect(result.current.dragActive).toBe(true);
|
|
act(() => result.current.onDragLeave());
|
|
expect(result.current.dragActive).toBe(false);
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-011: picking an older version asks first, and confirming installs that version', async () => {
|
|
serve([row({ version: '2.0.0' })]);
|
|
vi.spyOn(adminApi, 'pluginDetail').mockResolvedValue({ versions: [] });
|
|
const update = vi
|
|
.spyOn(adminApi, 'pluginUpdate')
|
|
.mockResolvedValue({ version: '1.0.0', activated: true, newPermissions: [], newEgress: [] });
|
|
const { result } = await mounted();
|
|
act(() => result.current.openVersionPicker(result.current.plugins[0]!));
|
|
await waitFor(() => expect(result.current.versionPicker?.versions).toEqual([]));
|
|
act(() => result.current.pickVersion('1.0.0'));
|
|
expect(result.current.versionPicker).toBeNull();
|
|
expect(result.current.confirmDowngrade).toMatchObject({ version: '1.0.0' });
|
|
await act(async () => {
|
|
result.current.downgradeConfirmed();
|
|
});
|
|
expect(update).toHaveBeenCalledWith('alpha', '1.0.0');
|
|
expect(result.current.confirmDowngrade).toBeNull();
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-012: uninstall goes through the confirm, and the row menu names its plugin', async () => {
|
|
serve([row()]);
|
|
vi.spyOn(adminApi, 'pluginUninstall').mockResolvedValue({});
|
|
const { result } = await mounted();
|
|
act(() => result.current.setMenu('row:alpha'));
|
|
expect(result.current.rowMenuPlugin?.id).toBe('alpha');
|
|
act(() => result.current.askUninstall(result.current.plugins[0]!));
|
|
expect(result.current.menu).toBeNull();
|
|
expect(result.current.confirmUninstall?.id).toBe('alpha');
|
|
await act(async () => {
|
|
await result.current.uninstallConfirmed();
|
|
});
|
|
expect(adminApi.pluginUninstall).toHaveBeenCalledWith('alpha', true);
|
|
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
|
|
expect(result.current.confirmUninstall).toBeNull();
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
|
|
serve([
|
|
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
|
|
]);
|
|
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
|
|
const { result } = await mounted();
|
|
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
|
|
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
|
|
expect(result.current.bypass.warning).toMatchObject({
|
|
acceptedByAdmin: true,
|
|
action: 'activate',
|
|
trekRange: '>=3.2.0 <4.0.0',
|
|
});
|
|
await act(async () => {
|
|
result.current.bypass.confirm();
|
|
});
|
|
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
|
|
});
|
|
|
|
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
|
|
serve([], [entry({ id: 'beta', name: 'Beta' })]);
|
|
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
|
|
const { result } = await mounted();
|
|
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
|
|
act(() => result.current.install('beta', undefined, warn));
|
|
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
result.current.bypass.confirm();
|
|
});
|
|
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
|
|
});
|
|
});
|