mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
394201cd32
* chore: bump version to 4.1.0 [skip ci] * fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone Three places answered "is this a place the trip already has?", not two: PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and kept its own hand-rolled copy of the pre-#2130 order. Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of it — savePlace calls it directly — so the coordinate fallback for a NAMED candidate was live, not latent: two distinct places sharing an address (a restaurant and the bar upstairs) would report each other as duplicates the moment their coordinates round to the same ~11 m box, regardless of name. It also never read google_place_id/google_ftid/osm_id at all, even though every collection_places row stores them, so a place renamed since it was saved could be saved again instead of being recognised by its provider id. Now walks placeMatchStrategies() from @trek/shared, same as findMatchingPlaceId: provider id, then name, then coordinates only when there is no name. savePlace passes the provider id fields through since they are already on the request body; the two bulk call sites (importablePlaces, saveFromTripPlaces) are unchanged, since their source rows don't carry provider ids to pass. Closes #2137 * fix(collections): ask the same question at all three call sites savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy already carries google_place_id/google_ftid/osm_id into the row it writes but asked without them, and the import picker never selected them at all, so a renamed place was recognised on one path and not on the other two. The picker answering differently from the import is the drift the method exists to prevent: a row shown as new would come back refused. COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker. * fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom specified", and so could the map settings tab, which left no screen from which to pick a different basemap. Leaflet answers getMaxZoom() from the map options first and otherwise from a layer that brought one, and only a GridLayer ever contributes via its beforeAdd hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd refuses an infinite one by throwing, before any clustering, so an empty trip hit it too. That path opened when the default basemap became a MapLibre style. It reaches further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless CARTO template to the app default, which is now a vector style, so every instance still carrying the old CARTO basemap fell into it on upgrade. The ceiling belongs on the map rather than on the base layer, because it has to hold whichever of the three branches renders. 19 matches what the raster and satellite layers already carried, so nothing changes for the maps that worked. SharedTripPage gets the same treatment: it has no cluster to throw, but it draws the same vector basemap and its fitBounds asks for a ceiling. mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library rather than a mock, since a mocked react-leaflet would have kept passing through all of this. * fix(map): put the CARTO key back on the template the settings preview draws The map settings fields hold what the user is editing rather than what useTileUrl already resolved, so the preview received a bare template with no key on it. resolveTileUrl then read it as a keyless CARTO url and did what it is meant to do with one: fall back to the app default. The preview drew OpenFreeMap while the fields said CARTO, whether or not a key was saved. Same reason the tab crashed for anyone on a CARTO basemap even after entering a key, which is the loop that made this hard to escape: the key is entered on the page the fallback had taken down. FE-COMP-MAP-033b pins the template the preview is handed, with and without a key. * fix(admin): put the CARTO key back on the default-settings map preview too Same shape as the user-facing map tab, and reported alongside it: the field holds what the admin is editing, so the preview resolved a keyless CARTO template and drew the app default instead of the basemap being set. * fix(mobile): put the CARTO key back on the phone map settings preview too The third preview with the same shape, and the one that matters most while the crash is live: the phone layout is where someone would go to change the basemap when the desktop tab will not open. * fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar Closes #2136 A stay was always exported as an all-day block across its whole range, and the check-in and check-out markers came on top of it. When the stay records both ends of its clock the markers already say everything the block does, and the block is the half nobody can act on: it takes a week of calendar to repeat what two one-hour events state precisely. Same shape as the parking and rental windows in #2068. Only where the markers actually stand in for the booking, though. They are emitted once per stay and titled from its lowest-id reservation, so a second room on the same stay would otherwise be left with nothing at all. A stay that knows only one end keeps its block too, since nothing else carries the other end's date. CAL-025b covers the drop, -025c the one-sided stay, -025d the second room. * fix(mobile): the phone admin map preview needs the CARTO key too The fourth preview with the same shape, missed when the other three were fixed. Sonar pointed at it: the desktop and phone admin pages are near-identical, so the duplication report named the file the change had not reached. * chore: bump version to 4.1.1 [skip ci] * feat(plugins): admin editor for instance-wide plugin settings The scope:'instance' settings a plugin declares had API endpoints but no UI. - shared: plugins.schema.ts — the settings-field descriptor and the admin instance-config GET/PUT wire contracts, imported by both sides - server: GET /api/admin/plugins/:id/config now returns the declared fields alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child reads config once, at init) and reports it via 'restarted'; the admin list carries instanceSettingsCount so the UI gates its menu item without a fetch - client: 'Instance settings' row action opening a form modal (desktop) / MSheet (phone) in both admin shells — checkbox/select/secret rendering as in the user settings form; an untouched secret mask is never sent back - i18n: three new admin.plugins.* keys in every locale * refactor(plugins): move the admin body contracts to @trek/shared The remaining plugin DTO schemas (install, link, activate, uninstall, retrust, update, egress-hosts, user-settings update, route) now live in shared/src/plugins/plugins.schema.ts with their inferred types, alongside the instance-config contracts. The journey-contract doctrine notes (#1842) moved with them — each body stays deliberately loose so the handlers keep the rejections and fallbacks they already own, and new spec tests pin that looseness so a well-meant tightening breaks a test that explains itself. plugins.dto.ts is now thin createZodDto wrappers over the shared schemas (same class names, no controller changes), and the client's pluginInstall types its options and body against PluginInstallRequest. * fix(admin): offer Allowed hosts only to plugins that declared operatorEgress The row's egress chip was already gated on the declaration, but the kebab menu (desktop) and row action sheet (phone) offered Allowed hosts on every installed plugin — inviting the admin to widen egress for plugins that never asked for it. Both entries now render only when the plugin declares operatorEgress; the dialog's unsupported-notice fallback stays as the truth-teller for stale list data. * test(admin): close the Sonar gaps on the instance-settings form The PR gate flagged new-code coverage (76.4%) and duplication (10.9%). - The two identical 39-line handler blocks were logic, not markup — they move into one shared useInstanceSettings hook; each shell keeps its own markup (the deliberate desktop/phone divergence stays) - New tests per shell cover the untested branches: every field type renders and round-trips into the save payload (select, checkbox/MToggle, number, hint, no-label key fallback), a failed fetch is a toast, a rejected save shows the server reason and keeps the edits, and the dialog/sheet closes without saving - INS-009 covers the controller's no-body '|| {}' fallback --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com> Co-authored-by: Maurice <mauriceboe@icloud.com>
1661 lines
81 KiB
TypeScript
1661 lines
81 KiB
TypeScript
// FE-MOB-PLUGP-001 onwards
|
|
import { http, HttpResponse } from 'msw';
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
|
|
import { resetAllStores } from '../../../helpers/store';
|
|
import { server } from '../../../helpers/msw/server';
|
|
import MAdminPluginsPanel from '../../../../src/mobile/screens/admin/MAdminPluginsPanel';
|
|
|
|
type Row = Record<string, unknown>;
|
|
|
|
function plugin(over: Row = {}): Row {
|
|
return {
|
|
id: 'trek-gotify',
|
|
name: 'Gotify',
|
|
description: 'Push notifications',
|
|
type: 'integration',
|
|
icon: 'Bell',
|
|
version: '1.0.0',
|
|
status: 'active',
|
|
enabled: 1,
|
|
last_error: null,
|
|
reviewed_at: null,
|
|
source_repo: null,
|
|
permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']),
|
|
capabilities: '{}',
|
|
operatorEgress: false,
|
|
egressHostCount: 0,
|
|
dependencyStatus: 'ok',
|
|
dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] },
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function registryEntry(over: Row = {}): Row {
|
|
return {
|
|
id: 'trek-gotify',
|
|
name: 'Gotify',
|
|
author: 'Acme',
|
|
description: 'Push notifications for TREK',
|
|
repo: 'acme/gotify',
|
|
type: 'integration',
|
|
latest: '2.0.0',
|
|
// The real browse response always carries latestCompatible (server-side hostCompat);
|
|
// mirror `latest` by default, exactly like a fully-compatible entry.
|
|
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
|
|
minTrekVersion: null,
|
|
reviewedAt: null,
|
|
screenshotUrl: null,
|
|
downloadCount: null,
|
|
signed: true,
|
|
authorPublicKey: 'untrusted comment: minisign public key\nRWQ12345678MIDDLE87654321',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** The two calls every mount makes: the installed list, then (if non-empty) the registry. */
|
|
function mockPanel(plugins: Row[], registry: Row[] = [], extra: Row = {}) {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () =>
|
|
HttpResponse.json({ enabled: true, devLink: false, plugins, ...extra })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)),
|
|
);
|
|
}
|
|
|
|
let toasts: Array<{ message: string; type: string }> = [];
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
toasts = [];
|
|
// useToast is a thin wrapper around this global; the ToastContainer is never mounted here.
|
|
window.__addToast = ((message: string, type?: string) => {
|
|
toasts.push({ message, type: type ?? 'info' });
|
|
return toasts.length;
|
|
}) as Window['__addToast'];
|
|
// refresh() also nudges the app-wide active-plugin store.
|
|
server.use(http.get('*/api/plugins', () => HttpResponse.json({ plugins: [] })));
|
|
});
|
|
|
|
function toastMessages() {
|
|
return toasts.map(t => t.message);
|
|
}
|
|
|
|
// MSheet binds its Escape listener in an effect, so a key fired the moment the
|
|
// sheet's content appears can land before the listener exists. Retry until the
|
|
// sheet is actually gone rather than pressing once and hoping.
|
|
const escapeUntilGone = (text: string) =>
|
|
waitFor(() => {
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
expect(screen.queryByText(text)).not.toBeInTheDocument();
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — load states', () => {
|
|
it('FE-MOB-PLUGP-001: shows the load error when the plugin list cannot be fetched', async () => {
|
|
server.use(http.get('*/api/admin/plugins', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
expect(await screen.findByText('Could not load plugins.')).toBeInTheDocument();
|
|
// No toolbar behind an error — there is nothing to filter.
|
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-002: explains that the runtime is off and hides the toolbar', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: false, plugins: [] })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Plugins are disabled')).toBeInTheDocument();
|
|
expect(screen.queryByText('Runtime on')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-003: an empty install list offers the Discover jump', async () => {
|
|
mockPanel([], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('No plugins installed yet.')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /Discover/ }));
|
|
|
|
// The registry only loads once the tab is opened for an empty instance.
|
|
expect(await screen.findByText('Acme')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the installed row', () => {
|
|
it('FE-MOB-PLUGP-004: renders name, version, runtime badge and the capability chips', async () => {
|
|
mockPanel([plugin()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Gotify')).toBeInTheDocument();
|
|
expect(screen.getByText('Runtime on')).toBeInTheDocument();
|
|
expect(screen.getByText('v1.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('Push notifications')).toBeInTheDocument();
|
|
// Derived from the permission list, not from a separate field.
|
|
expect(screen.getByText('Notification channel')).toBeInTheDocument();
|
|
expect(screen.getByText('gotify.net')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-005: covers the whole capability vocabulary, including a replaced planner tab', async () => {
|
|
mockPanel([plugin({
|
|
permissions: JSON.stringify([
|
|
'db:read:trips', 'db:read:users', 'db:write:costs', 'db:read:packing', 'db:read:files',
|
|
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:meta',
|
|
'ws:broadcast:trip', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider',
|
|
'hook:trip-warning-provider', 'hook:map-layer-provider', 'hook:route-provider',
|
|
'hook:day-schedule-provider', 'geolocation:read', 'events:subscribe',
|
|
]),
|
|
capabilities: JSON.stringify({ widget: { slot: 'hero' }, tripPage: { replaces: ['costs'] } }),
|
|
})]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Gotify');
|
|
for (const label of [
|
|
'Reads your trips', 'Reads basic profiles', 'Adds costs', 'Reads packing lists', 'Reads trip files',
|
|
'Edits places', 'Edits days', 'Edits itinerary', 'Edits trips', 'Adds metadata',
|
|
'Boarding-pass widget', 'Replaces planner tabs', 'Real-time updates', 'Provides photos',
|
|
'Provides calendar events', 'Enriches places', 'Flags issues', 'Draws on the map',
|
|
'Offers routing', 'Adds plan times', 'Reads your position', 'Reacts to activity',
|
|
]) {
|
|
expect(screen.getByText(label)).toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-006: read-only costs downgrade the costs chip and a broken permissions blob degrades to none', async () => {
|
|
mockPanel([
|
|
plugin({ id: 'reader', name: 'Reader', permissions: JSON.stringify(['db:read:costs']) }),
|
|
plugin({ id: 'broken', name: 'Broken', permissions: 'not-json', capabilities: 'not-json' }),
|
|
]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Reads your costs')).toBeInTheDocument();
|
|
expect(screen.queryByText('Adds costs')).not.toBeInTheDocument();
|
|
// The unparseable row still renders — a bad blob must not take the panel down.
|
|
expect(screen.getByText('Broken')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-007: an errored plugin shows its last error instead of the chips', async () => {
|
|
mockPanel([plugin({ status: 'error', last_error: 'child exited with code 1' })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('child exited with code 1')).toBeInTheDocument();
|
|
expect(screen.queryByText('Notification channel')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-008: dependency chips name the blocker — addon, plugin and TREK range', async () => {
|
|
mockPanel([plugin({
|
|
enabled: 0,
|
|
status: 'inactive',
|
|
dependencyStatus: 'hostIncompatible',
|
|
trekRange: '>=3.2.0 <4.0.0',
|
|
hostVersion: '4.0.0',
|
|
dependencies: { requiredAddons: ['budget'], pluginDependencies: [{ id: 'trek-base', version: '^1.0.0' }] },
|
|
dependencyIssues: { disabledAddons: ['budget'], missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] },
|
|
})]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Needs TREK >=3.2.0 <4.0.0 — this server runs 4.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('Requires budget')).toBeInTheDocument();
|
|
expect(screen.getByText('Needs trek-base ^1.0.0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-009: a plugin that never declared a TREK range says so', async () => {
|
|
mockPanel([plugin({ dependencyStatus: 'hostIncompatible', trekRange: null })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Does not say which TREK versions it supports')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-010: the source badge wins over the trust badge', async () => {
|
|
mockPanel([
|
|
plugin({ id: 'a', name: 'Registry one', source_repo: 'acme/gotify', signed: true, reviewed_at: '2026-01-01' }),
|
|
plugin({ id: 'b', name: 'Registry two', source_repo: 'acme/other', signed: false }),
|
|
plugin({ id: 'c', name: 'Uploaded', source_repo: 'local:upload', signed: false }),
|
|
plugin({ id: 'd', name: 'Linked', source_repo: 'local:link', signed: false }),
|
|
]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Registry one');
|
|
expect(screen.getByText('Signed')).toBeInTheDocument();
|
|
// Exactly one Unsigned — the sideloaded and dev-linked rows say something stronger.
|
|
expect(screen.getAllByText('Unsigned')).toHaveLength(1);
|
|
expect(screen.getByText('Sideloaded')).toBeInTheDocument();
|
|
expect(screen.getByText('Dev-Link')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Reviewed')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — toolbar', () => {
|
|
const rows = [
|
|
plugin({ id: 'alpha', name: 'Alpha', description: 'the first', type: 'widget', enabled: 1, status: 'active' }),
|
|
plugin({ id: 'beta', name: 'Beta', description: 'the second', type: 'page', enabled: 0, status: 'inactive' }),
|
|
plugin({ id: 'gamma', name: 'Gamma', description: 'the third', type: 'integration', status: 'error', last_error: 'nope' }),
|
|
];
|
|
|
|
it('FE-MOB-PLUGP-011: the search box filters on name and description', async () => {
|
|
mockPanel(rows);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Alpha');
|
|
|
|
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'second' } });
|
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'nothing here' } });
|
|
expect(screen.getByText('No installed plugins match your search.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-012: the type sheet narrows the list to one plugin type', async () => {
|
|
mockPanel(rows);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Alpha');
|
|
|
|
fireEvent.click(screen.getByTitle('Type: All types'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Page' }));
|
|
|
|
await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
expect(screen.getByTitle('Type: Page')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-013: the status sheet separates on, off and errored plugins', async () => {
|
|
mockPanel(rows);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Alpha');
|
|
|
|
fireEvent.click(screen.getByTitle('Status: All'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Off' }));
|
|
await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByTitle('Status: Off'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Error' }));
|
|
await waitFor(() => expect(screen.queryByText('Beta')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Gamma')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByTitle('Status: Error'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Active' }));
|
|
await waitFor(() => expect(screen.queryByText('Gamma')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-014: "Updates first" ranks the updatable plugin above an alphabetically earlier one', async () => {
|
|
mockPanel(
|
|
[
|
|
plugin({ id: 'alpha', name: 'Alpha', version: '1.0.0' }),
|
|
plugin({ id: 'zulu', name: 'Zulu', version: '1.0.0' }),
|
|
],
|
|
[registryEntry({ id: 'zulu', name: 'Zulu', latest: '2.0.0' }), registryEntry({ id: 'alpha', name: 'Alpha', latest: '1.0.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('1 updates available for your plugins.');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Updates first' }));
|
|
|
|
await waitFor(() => {
|
|
const names = screen.getAllByText(/^(Alpha|Zulu)$/).map(n => n.textContent);
|
|
expect(names).toEqual(['Zulu', 'Alpha']);
|
|
});
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-015: the "update available" status filter keeps only the plugin with an update', async () => {
|
|
mockPanel(
|
|
[plugin({ id: 'alpha', name: 'Alpha', version: '1.0.0' }), plugin({ id: 'zulu', name: 'Zulu', version: '9.0.0' })],
|
|
[registryEntry({ id: 'alpha', name: 'Alpha', latest: '2.0.0' }), registryEntry({ id: 'zulu', name: 'Zulu', latest: '9.0.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('1 updates available for your plugins.');
|
|
|
|
fireEvent.click(screen.getByTitle('Status: All'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update available' }));
|
|
|
|
await waitFor(() => expect(screen.queryByText('Zulu')).not.toBeInTheDocument());
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — Discover', () => {
|
|
async function openDiscover(entries: Row[]) {
|
|
mockPanel([], entries);
|
|
const view = render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
return view;
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-016: a registry card carries author, type, version, downloads and the reviewed ribbon', async () => {
|
|
await openDiscover([registryEntry({ reviewedAt: '2026-01-05T00:00:00Z', downloadCount: 1234, screenshotUrl: null })]);
|
|
|
|
expect(await screen.findByText('Acme')).toBeInTheDocument();
|
|
expect(screen.getByText('Integration')).toBeInTheDocument();
|
|
expect(screen.getByText('v2.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('1.2k')).toBeInTheDocument();
|
|
expect(screen.getAllByText('Reviewed').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-017: installing from a card posts the id and reports success', async () => {
|
|
let body: unknown = null;
|
|
await openDiscover([registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /^Install$/ }));
|
|
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Installed'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-018: an entry no published version fits gets a dead, explained button', async () => {
|
|
await openDiscover([registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })]);
|
|
|
|
const btn = await screen.findByRole('button', { name: 'Incompatible' });
|
|
expect(btn).toBeDisabled();
|
|
expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-019: when only an older release fits, that version is offered and installed', async () => {
|
|
let body: unknown = null;
|
|
await openDiscover([registryEntry({ trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' })]);
|
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Install 1.5.0' }));
|
|
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-020: an already-installed plugin cannot be installed twice', async () => {
|
|
mockPanel([plugin()], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
|
|
const btn = await screen.findByRole('button', { name: 'Installed' });
|
|
expect(btn).toBeDisabled();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-021: an empty registry and a filtered-to-nothing registry read differently', async () => {
|
|
await openDiscover([]);
|
|
expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-022: a search with no registry hit says so', async () => {
|
|
await openDiscover([registryEntry()]);
|
|
await screen.findByText('Acme');
|
|
|
|
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzz' } });
|
|
expect(screen.getByText('No plugins in the registry match your search.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-023: an unreachable registry degrades to an empty list, not a crash', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: /Discover/ }));
|
|
|
|
expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-024: Discover sorts by downloads and by review date', async () => {
|
|
await openDiscover([
|
|
registryEntry({ id: 'a', name: 'Aaa', downloadCount: 5, reviewedAt: '2026-01-01T00:00:00Z' }),
|
|
registryEntry({ id: 'z', name: 'Zzz', downloadCount: 900, reviewedAt: '2026-06-01T00:00:00Z' }),
|
|
]);
|
|
await screen.findByText('Aaa');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Most downloads' }));
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/^(Aaa|Zzz)$/).map(n => n.textContent)).toEqual(['Zzz', 'Aaa']);
|
|
});
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Most downloads'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Recently updated' }));
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText(/^(Aaa|Zzz)$/).map(n => n.textContent)).toEqual(['Zzz', 'Aaa']);
|
|
});
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-025: switching back to Installed drops a sort key that tab cannot offer', async () => {
|
|
await openDiscover([registryEntry()]);
|
|
await screen.findByText('Acme');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Most downloads' }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Most downloads')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('tab', { name: /Installed/ }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-026: a broken screenshot falls back to the placeholder tile', async () => {
|
|
const { container } = await openDiscover([registryEntry({ screenshotUrl: 'https://example.test/shot.png' })]);
|
|
|
|
await screen.findByText('Acme');
|
|
const img = container.querySelector('img') as HTMLImageElement;
|
|
expect(img).toHaveAttribute('src', 'https://example.test/shot.png');
|
|
|
|
fireEvent.error(img);
|
|
await waitFor(() => expect(container.querySelector('img')).toBeNull());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-027: the card opens the detail sheet from the keyboard too', async () => {
|
|
await openDiscover([registryEntry()]);
|
|
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () =>
|
|
HttpResponse.json({ ...registryEntry(), size: null, publishedAt: null, manifest: null })));
|
|
|
|
const card = (await screen.findByText('Acme')).closest('[role="button"]') as HTMLElement;
|
|
fireEvent.keyDown(card, { key: 'Enter' });
|
|
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the registry detail sheet', () => {
|
|
const manifest = {
|
|
permissions: ['db:own', 'db:read:trips', 'http:outbound:gotify.net'],
|
|
egress: ['gotify.net'],
|
|
operatorEgress: true,
|
|
settings: [{ key: 'token', label: 'API token', inputType: 'password', scope: 'instance', required: true }],
|
|
license: 'MIT',
|
|
icon: 'Bell',
|
|
};
|
|
|
|
async function openDetail(detail: Row, entry: Row = registryEntry()) {
|
|
mockPanel([], [entry]);
|
|
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detail)));
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByText('Acme'));
|
|
return screen.findByRole('dialog');
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-028: lists access, outbound hosts, the operator caveat, setup fields and the meta grid', async () => {
|
|
const entry = registryEntry({ reviewedAt: '2026-01-05T00:00:00Z', downloadCount: 4200, trek: '>=3.2.0 <4.0.0' });
|
|
await openDetail({ ...entry, size: 4096, publishedAt: null, manifest }, entry);
|
|
|
|
expect(await screen.findByText('What it can access')).toBeInTheDocument();
|
|
expect(screen.getByText('Reads your trips')).toBeInTheDocument();
|
|
expect(screen.getByText('Store its own data in an isolated database')).toBeInTheDocument();
|
|
expect(screen.getByText('gotify.net')).toBeInTheDocument();
|
|
expect(screen.getByText('+ hosts you add')).toBeInTheDocument();
|
|
expect(screen.getByText('API token')).toBeInTheDocument();
|
|
expect(screen.getByText('Instance-wide')).toBeInTheDocument();
|
|
expect(screen.getByText('Required')).toBeInTheDocument();
|
|
expect(screen.getByText('4 KB')).toBeInTheDocument();
|
|
expect(screen.getByText('TREK >=3.2.0 <4.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('4,200')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-029: a plugin that needs nothing says so, and falls back to the min-version line', async () => {
|
|
await openDetail(
|
|
{ ...registryEntry({ minTrekVersion: '3.0.0' }), size: null, publishedAt: null,
|
|
manifest: { permissions: [], egress: [], settings: [], license: null, icon: null } },
|
|
registryEntry({ minTrekVersion: '3.0.0' }),
|
|
);
|
|
|
|
expect(await screen.findByText('Needs no special access.')).toBeInTheDocument();
|
|
expect(screen.getByText('TREK 3.0.0+')).toBeInTheDocument();
|
|
expect(screen.queryByText('Connects to')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-092: the manifest capabilities are chipped before the install decision', async () => {
|
|
await openDetail({
|
|
...registryEntry(),
|
|
size: null,
|
|
publishedAt: null,
|
|
manifest: {
|
|
...manifest,
|
|
capabilities: { widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } },
|
|
},
|
|
});
|
|
|
|
expect(await screen.findByText('Replaces planner tabs')).toBeInTheDocument();
|
|
expect(screen.getByText('Boarding-pass widget')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-030: a failed detail fetch is reported inside the sheet', async () => {
|
|
mockPanel([], [registryEntry()]);
|
|
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () =>
|
|
HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByText('Acme'));
|
|
|
|
expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-031: the blocked reason is spelled out in the sheet, not just as a tooltip', async () => {
|
|
const entry = registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null });
|
|
await openDetail({ ...entry, size: null, publishedAt: null, manifest: null }, entry);
|
|
|
|
expect(await screen.findAllByText('Needs TREK >=4.0.0 — this server runs 3.3.0')).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-032: a distinct homepage gets its own link, and the sheet closes again', async () => {
|
|
await openDetail(
|
|
{ ...registryEntry({ homepage: 'https://gotify.example' }), size: null, publishedAt: null, manifest: null },
|
|
registryEntry({ homepage: 'https://gotify.example' }),
|
|
);
|
|
|
|
const dialog = await screen.findByRole('dialog');
|
|
expect(within(dialog).getByRole('link', { name: /Homepage/ })).toHaveAttribute('href', 'https://gotify.example');
|
|
expect(within(dialog).getByRole('link', { name: /Source repository/ })).toHaveAttribute('href', 'https://github.com/acme/gotify');
|
|
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-033: installing straight from the sheet uses the offered version', async () => {
|
|
let body: unknown = null;
|
|
const entry = registryEntry({ trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' });
|
|
await openDetail({ ...entry, size: null, publishedAt: null, manifest: null }, entry);
|
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
const dialog = await screen.findByRole('dialog');
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Install 1.5.0' }));
|
|
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }));
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the row action sheet', () => {
|
|
async function openRowMenu(over: Row = {}) {
|
|
mockPanel([plugin(over)]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
return screen.findByRole('dialog');
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-CFG-005: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => {
|
|
// An admin must never be invited to widen egress for a plugin that didn't ask for it
|
|
// — same rule the row's egress chip already follows. (Fixture default: operatorEgress false.)
|
|
const dialog = await openRowMenu();
|
|
|
|
expect(within(dialog).getByText('View error log')).toBeInTheDocument(); // sheet is open
|
|
expect(within(dialog).queryByText('Allowed hosts')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-034: a registry plugin offers every action plus the repository links', async () => {
|
|
const dialog = await openRowMenu({ source_repo: 'acme/gotify', operatorEgress: true });
|
|
|
|
for (const label of ['Restart', 'View error log', 'Allowed hosts', 'Source repository', 'Report an issue', 'Delete']) {
|
|
expect(within(dialog).getByText(label)).toBeInTheDocument();
|
|
}
|
|
expect(within(dialog).getByRole('link', { name: /Report an issue/ }))
|
|
.toHaveAttribute('href', 'https://github.com/acme/gotify/issues');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-035: a sideloaded, disabled plugin gets neither Restart nor repo links', async () => {
|
|
const dialog = await openRowMenu({ source_repo: 'local:upload', enabled: 0, status: 'inactive' });
|
|
|
|
expect(within(dialog).queryByText('Restart')).not.toBeInTheDocument();
|
|
expect(within(dialog).queryByText('Source repository')).not.toBeInTheDocument();
|
|
expect(within(dialog).getByText('Delete')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-036: Restart cycles the plugin off and on', async () => {
|
|
const calls: string[] = [];
|
|
await openRowMenu();
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { calls.push('deactivate'); return HttpResponse.json({ ok: true }); }),
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () => { calls.push('activate'); return HttpResponse.json({ ok: true }); }),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText('Restart'));
|
|
await waitFor(() => expect(calls).toEqual(['deactivate', 'activate']));
|
|
expect(toastMessages()).toContain('Plugin restarted');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-037: the error log sheet lists the recorded lines', async () => {
|
|
await openRowMenu();
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () =>
|
|
HttpResponse.json({ errors: [{ ts: '2026-07-01T10:00:00Z', level: 'error', message: 'ECONNREFUSED' }] })));
|
|
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
expect(await screen.findByText('ECONNREFUSED')).toBeInTheDocument();
|
|
expect(screen.getByText('error')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-038: a failing error-log fetch still opens the sheet, empty', async () => {
|
|
await openRowMenu();
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () =>
|
|
HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
expect(await screen.findByText('No errors logged.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-039: Delete asks first, then uninstalls with its data', async () => {
|
|
let body: unknown = null;
|
|
await openRowMenu();
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
|
|
fireEvent.click(screen.getByText('Delete'));
|
|
expect(await screen.findByText('Uninstall plugin?')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
|
|
|
await waitFor(() => expect(body).toEqual({ deleteData: true }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin uninstalled'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-040: cancelling the uninstall confirmation calls nothing', async () => {
|
|
let called = false;
|
|
await openRowMenu();
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', () => { called = true; return HttpResponse.json({ ok: true }); }));
|
|
|
|
fireEvent.click(screen.getByText('Delete'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByText('Uninstall plugin?')).not.toBeInTheDocument());
|
|
expect(called).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — operator-supplied egress hosts', () => {
|
|
it('FE-MOB-PLUGP-041: the chip invites a first host and turns into a count once one exists', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
const { unmount } = render(<MAdminPluginsPanel />);
|
|
expect(await screen.findByRole('button', { name: 'Add allowed host' })).toBeInTheDocument();
|
|
unmount();
|
|
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 2 })]);
|
|
render(<MAdminPluginsPanel />);
|
|
expect(await screen.findByRole('button', { name: '2 allowed host(s)' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-042: the sheet adds and removes hosts through the API', async () => {
|
|
const bodies: unknown[] = [];
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 1 })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.test'] })),
|
|
http.put('*/api/admin/plugins/trek-gotify/egress-hosts', async ({ request }) => {
|
|
const body = await request.json() as { hosts: string[] };
|
|
bodies.push(body);
|
|
return HttpResponse.json({ hosts: body.hosts });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '1 allowed host(s)' }));
|
|
expect(await screen.findByText('gotify.mydomain.test')).toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: ' ntfy.mydomain.test ' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
|
await waitFor(() => expect(bodies[0]).toEqual({ hosts: ['gotify.mydomain.test', 'ntfy.mydomain.test'] }));
|
|
|
|
fireEvent.click((await screen.findAllByRole('button', { name: 'Delete' }))[0]);
|
|
await waitFor(() => expect(bodies[1]).toEqual({ hosts: ['ntfy.mydomain.test'] }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-043: a rejected save surfaces the server message and keeps the sheet open', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts: [] })),
|
|
http.put('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ error: 'not a hostname' }, { status: 400 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' }));
|
|
expect(await screen.findByText('No hosts added yet.')).toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'not a host' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
|
expect(await screen.findByText('not a hostname')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-044: a plugin with fixed manifest hosts says the sheet does not apply', async () => {
|
|
// Declares operatorEgress (so the menu item shows), but the runtime lookup
|
|
// disagrees — the sheet's unsupported notice is the truth-teller.
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', operatorEgress: true })]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ supported: false, hosts: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(screen.getByText('Allowed hosts'));
|
|
expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-045: a failing hosts fetch degrades to the unsupported notice', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' }));
|
|
expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — updates and consent', () => {
|
|
it('FE-MOB-PLUGP-046: the updates bar updates every outdated plugin at once', async () => {
|
|
const updated: string[] = [];
|
|
mockPanel(
|
|
[plugin({ id: 'a', name: 'Aaa', version: '1.0.0' }), plugin({ id: 'b', name: 'Bbb', version: '1.0.0' })],
|
|
[registryEntry({ id: 'a', name: 'Aaa', latest: '2.0.0' }), registryEntry({ id: 'b', name: 'Bbb', latest: '1.1.0' })],
|
|
);
|
|
server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
|
|
updated.push(String(params.id));
|
|
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('2 updates available for your plugins.')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Update all' }));
|
|
await waitFor(() => expect(updated.sort()).toEqual(['a', 'b']));
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin updated'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046a: an update this TREK cannot install is neither offered nor counted', async () => {
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
|
|
[registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Gotify');
|
|
|
|
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046b: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
|
|
[registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046f: a held plugin leaves the banner and offers Resume updates instead', async () => {
|
|
let resumed = false;
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0', updateHold: true })],
|
|
[registryEntry({ latest: '2.0.0' })],
|
|
);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
|
|
resumed = true;
|
|
return HttpResponse.json({ updateHold: false });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Gotify');
|
|
|
|
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
|
|
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /resume updates/i }));
|
|
await waitFor(() => expect(resumed).toBe(true));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046d: the detail sheet lists every version and installs the picked one', async () => {
|
|
const bodies: unknown[] = [];
|
|
mockPanel([], [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({
|
|
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
|
|
size: 1024, publishedAt: null, manifest: null,
|
|
versions: [
|
|
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
|
|
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
],
|
|
})),
|
|
http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
bodies.push(await request.json());
|
|
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByText('Acme'));
|
|
|
|
// The incompatible latest is explained, never offered.
|
|
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }));
|
|
await waitFor(() => expect(bodies).toEqual([{ id: 'trek-gotify', version: '1.5.0' }]));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046e: Change version rolls back through update, after an explicit data warning', async () => {
|
|
let body: unknown = null;
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '2.0.0' })],
|
|
[registryEntry()],
|
|
);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({
|
|
...registryEntry(), size: 1024, publishedAt: null, manifest: null,
|
|
versions: [
|
|
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
|
|
],
|
|
})),
|
|
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /change version/i }));
|
|
|
|
// The installed version is marked, not switchable.
|
|
const current = await screen.findByTestId('version-row-2.0.0');
|
|
expect(within(current).getByText('Installed')).toBeInTheDocument();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }));
|
|
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: /roll back/i }));
|
|
|
|
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-046c: the update offer is the newest compatible version, not the absolute latest', async () => {
|
|
mockPanel(
|
|
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
|
|
[registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-047: a per-row update that widens rights opens the consent sheet and approving activates with consent', async () => {
|
|
let consentBody: unknown = null;
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', signed: true, version: '1.0.0' })], [registryEntry()]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips', 'x:unknown'], newEgress: ['api.acme.test'] })),
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', async ({ request }) => {
|
|
consentBody = await request.json();
|
|
return HttpResponse.json({ ok: true });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
|
|
// A known permission reads as prose, an unknown one as its raw code.
|
|
expect(screen.getByText('Read trips the acting user can access')).toBeInTheDocument();
|
|
expect(screen.getByText('x:unknown')).toBeInTheDocument();
|
|
expect(screen.getByText('api.acme.test')).toBeInTheDocument();
|
|
// The registry entry is signed, so no unsigned caveat.
|
|
expect(screen.queryByText(/Nothing ties this version to its author/)).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Approve & turn on' }));
|
|
await waitFor(() => expect(consentBody).toEqual({ consent: true }));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-048: "Keep off for now" drops the prompt and says the update stays off', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Keep off for now' }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Update installed — left off until you approve the new permissions'));
|
|
await waitFor(() => expect(screen.queryByText('This update needs new permissions')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-049: an unsigned update says nothing ties it to its author', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', signed: false, version: '1.0.0' })], [registryEntry({ signed: false })]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
expect(await screen.findByText(/Nothing ties this version to its author/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-050: a failed update reports the server message', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ error: 'registry unreachable' }, { status: 502 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('registry unreachable'));
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — signature refusals', () => {
|
|
const blocked = (code: string) => plugin({
|
|
source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa', version: '1.0.0',
|
|
updateBlock: { code, detail: 'the signing key changed', version: '2.0.0' },
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-051: the row keeps showing why the update was refused', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Update blocked — the signing key changed')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-052: the block goes quiet once the registry offers a newer version', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry({ latest: '3.0.0' })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Gotify');
|
|
await waitFor(() => expect(screen.queryByText(/Update blocked/)).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-053: a sideloaded plugin never shows an update block', async () => {
|
|
mockPanel([plugin({ source_repo: 'local:upload', updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'x', version: '2.0.0' } })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Sideloaded');
|
|
expect(screen.queryByText(/Update blocked/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-054: Review compares both fingerprints and the confirmation re-pins and updates in one call', async () => {
|
|
let body: unknown = null;
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText('Key it was installed with')).toBeInTheDocument();
|
|
expect(screen.getByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument();
|
|
// Fingerprint of the registry's new key: head…tail of the base64 payload.
|
|
expect(screen.getByText('RWQ12345…87654321')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Trust the new key & update' }));
|
|
await waitFor(() => expect(body).toEqual({
|
|
version: '2.0.0',
|
|
publicKey: 'untrusted comment: minisign public key\nRWQ12345678MIDDLE87654321',
|
|
}));
|
|
await waitFor(() => expect(toastMessages()).toContain('New signing key trusted — the plugin is updated'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-055: a re-trust whose update widens rights still asks for consent', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
|
|
HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Trust the new key & update' }));
|
|
|
|
expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-056: a rejected re-trust reports the server message', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () =>
|
|
HttpResponse.json({ error: 'key changed again' }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Trust the new key & update' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('key changed again'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-057: an invalid signature is explained and offers no override at all', async () => {
|
|
mockPanel([blocked('SIGNATURE_INVALID')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText(/do not match the author's signature/i)).toBeInTheDocument();
|
|
// The raw server detail is shown instead of a key comparison that would imply a choice.
|
|
expect(screen.getByText('the signing key changed')).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Key it is offering now')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-058: a missing and a half-signed signature each get their own explanation', async () => {
|
|
mockPanel([blocked('SIGNATURE_MISSING')], [registryEntry()]);
|
|
const { unmount } = render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText(/ships no signature/i)).toBeInTheDocument();
|
|
unmount();
|
|
|
|
mockPanel([blocked('SIGNATURE_INCOMPLETE')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText(/half-signed/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-059: an update refused on its signature opens the dialog instead of a toast', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDKEYaa…aaaaaaaa' })], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/update', () =>
|
|
HttpResponse.json({ error: 'signing key changed', code: 'SIGNATURE_KEY_CHANGED' }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' }));
|
|
expect(await screen.findByText("Gotify's signature could not be verified")).toBeInTheDocument();
|
|
expect(toastMessages()).not.toContain('signing key changed');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-060: a fresh install refused for an invalid signature names the plugin from the registry entry', async () => {
|
|
mockPanel([], [registryEntry()]);
|
|
server.use(http.post('*/api/admin/plugins/install', () =>
|
|
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /^Install$/ }));
|
|
|
|
expect(await screen.findByText("Gotify's signature could not be verified")).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-061: the dialog closes again without re-trusting', async () => {
|
|
mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Do not trust it' }));
|
|
await waitFor(() => expect(screen.queryByText('Key it was installed with')).not.toBeInTheDocument());
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — enabling a plugin', () => {
|
|
const off = (over: Row = {}) => plugin({ enabled: 0, status: 'inactive', ...over });
|
|
|
|
it('FE-MOB-PLUGP-062: the toggle turns an active plugin off', async () => {
|
|
let called = false;
|
|
mockPanel([plugin()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { called = true; return HttpResponse.json({ ok: true }); }));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
await waitFor(() => expect(called).toBe(true));
|
|
expect(toastMessages()).toContain('Plugin deactivated');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-063: enabling a plugin also reports the dependencies it switched on for you', async () => {
|
|
// Named so the parent sorts first — the rows are ordered by name, and the toggle is picked by position.
|
|
mockPanel([
|
|
off({ dependencies: { requiredAddons: [], pluginDependencies: [{ id: 'trek-base', version: '^1.0.0' }] } }),
|
|
plugin({ id: 'trek-base', name: 'Zzz Base', enabled: 0, status: 'inactive' }),
|
|
]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => HttpResponse.json({ ok: true })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
await screen.findByText('Gotify');
|
|
fireEvent.click(screen.getAllByRole('switch', { name: 'Enable plugin' })[0]);
|
|
await waitFor(() => expect(toastMessages()).toContain('Enabled required plugin(s) first: Zzz Base'));
|
|
expect(toastMessages()).toContain('Plugin activated');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-064: a 409 CONSENT_REQUIRED routes to the consent sheet', async () => {
|
|
mockPanel([off({ source_repo: 'acme/gotify', signed: false })], []);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument();
|
|
// The registry is empty here, so the caveat falls back to the installed row's own flag.
|
|
expect(screen.getByText(/Nothing ties this version to its author/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-065: a 409 ADDON_DISABLED names the addons to switch on first', async () => {
|
|
mockPanel([off()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'addon disabled', code: 'ADDON_DISABLED', addons: ['budget', 'vacay'] }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Enable the required addon(s) first: budget, vacay'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-066: any other activation failure surfaces the server message', async () => {
|
|
mockPanel([off()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'dependency cycle', code: 'DEPENDENCY_CYCLE' }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('dependency cycle'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-067: a 409 DEPENDENCY_MISSING opens the resolve sheet, downloads and retries', async () => {
|
|
let installBody: unknown = null;
|
|
let activateCalls = 0;
|
|
mockPanel([off()]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () => {
|
|
activateCalls += 1;
|
|
if (activateCalls === 1) {
|
|
return HttpResponse.json({
|
|
error: 'missing dependency', code: 'DEPENDENCY_MISSING',
|
|
missing: [{ id: 'trek-base', version: '^1.0.0' }],
|
|
versionMismatch: [{ id: 'trek-old', wanted: '^2.0.0', installed: '1.0.0' }],
|
|
}, { status: 409 });
|
|
}
|
|
return HttpResponse.json({ ok: true });
|
|
}),
|
|
http.post('*/api/admin/plugins/install', async ({ request }) => {
|
|
installBody = await request.json();
|
|
return HttpResponse.json({ installed: ['trek-base'], requiredAddons: ['budget'] });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
expect(await screen.findByText('Missing dependencies')).toBeInTheDocument();
|
|
expect(screen.getByText('Requires ^1.0.0')).toBeInTheDocument();
|
|
expect(screen.getByText('Needs ^2.0.0 — 1.0.0 is installed')).toBeInTheDocument();
|
|
expect(screen.getByText('Downloads the latest compatible version, including its own dependencies.')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Download/ }));
|
|
await waitFor(() => expect(installBody).toEqual({ id: 'trek-base', constraint: '^1.0.0', withDependencies: true }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Downloaded trek-base'));
|
|
// The dependency install revealed a disabled addon on top.
|
|
expect(toastMessages()).toContain('Enable the required addon(s) first: budget');
|
|
await waitFor(() => expect(activateCalls).toBe(2));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-068: a signature refusal while downloading a dependency names the dependency', async () => {
|
|
mockPanel([off()]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
|
|
http.post('*/api/admin/plugins/install', () =>
|
|
HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /Download/ }));
|
|
|
|
expect(await screen.findByText("trek-base's signature could not be verified")).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-069: the resolve sheet can be dismissed without downloading', async () => {
|
|
mockPanel([off()]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument());
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — sideloading, dev-link and rescan', () => {
|
|
it('FE-MOB-PLUGP-070: the toolbar upload button installs the picked archive', async () => {
|
|
let uploaded = false;
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'trek-new' }); }));
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Upload plugin' }));
|
|
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip', { type: 'application/zip' })] } });
|
|
|
|
await waitFor(() => expect(uploaded).toBe(true));
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin “trek-new” uploaded — activate it to run'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-071: a rejected upload reports the server message', async () => {
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/upload', () =>
|
|
HttpResponse.json({ error: 'manifest missing' }, { status: 400 })));
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip')] } });
|
|
await waitFor(() => expect(toastMessages()).toContain('manifest missing'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-072: dragging an archive over the panel offers to install it, and dropping does', async () => {
|
|
let uploaded = false;
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'dropped' }); }));
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
const file = new File(['zip'], 'plugin.zip');
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [file] } });
|
|
expect(await screen.findByText('Drop a plugin .zip to install')).toBeInTheDocument();
|
|
|
|
fireEvent.drop(panel, { dataTransfer: { types: ['Files'], files: [file] } });
|
|
await waitFor(() => expect(uploaded).toBe(true));
|
|
await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-073: dragging something that is not a file never arms the overlay', async () => {
|
|
mockPanel([]);
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['text/plain'], files: [] } });
|
|
expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-074: leaving the panel again disarms the overlay', async () => {
|
|
mockPanel([]);
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [] } });
|
|
await screen.findByText('Drop a plugin .zip to install');
|
|
fireEvent.dragOver(panel, { dataTransfer: { types: ['Files'], files: [] } });
|
|
fireEvent.dragLeave(panel);
|
|
await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-075: the dev-link form registers a local build directory', async () => {
|
|
let body: unknown = null;
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: true, plugins: [] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([])),
|
|
http.post('*/api/admin/plugins/link', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ id: 'local-plugin' });
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin');
|
|
// An empty path is a no-op, not a request.
|
|
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
|
expect(body).toBeNull();
|
|
|
|
fireEvent.change(input, { target: { value: '/srv/plugins/local' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^Link$/ }));
|
|
|
|
await waitFor(() => expect(body).toEqual({ path: '/srv/plugins/local' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Linked local-plugin — activate it to run'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-076: a rejected dev-link reports the server message', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: true, plugins: [] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([])),
|
|
http.post('*/api/admin/plugins/link', () => HttpResponse.json({ error: 'no manifest there' }, { status: 400 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin');
|
|
fireEvent.change(input, { target: { value: '/nope' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^Link$/ }));
|
|
await waitFor(() => expect(toastMessages()).toContain('no manifest there'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-077: Rescan rediscovers local plugins and force-pulls the registry', async () => {
|
|
let rescanned = false;
|
|
let refreshParam: string | null = null;
|
|
mockPanel([]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/rescan', () => { rescanned = true; return HttpResponse.json({ ok: true }); }),
|
|
http.get('*/api/admin/plugins/registry', ({ request }) => {
|
|
refreshParam = new URL(request.url).searchParams.get('refresh');
|
|
return HttpResponse.json([registryEntry()]);
|
|
}),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Rescan' }));
|
|
await waitFor(() => expect(rescanned).toBe(true));
|
|
await waitFor(() => expect(refreshParam).toBe('1'));
|
|
expect(toastMessages()).toContain('Rescanned the plugins folder');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-078: a failed rescan falls back to the generic action error', async () => {
|
|
mockPanel([]);
|
|
server.use(http.post('*/api/admin/plugins/rescan', () => HttpResponse.json({}, { status: 500 })));
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Rescan' }));
|
|
await waitFor(() => expect(toastMessages()).toContain('Action failed'));
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — edge paths', () => {
|
|
it('FE-MOB-PLUGP-080: a block stands while the registry is unreachable, and offers no override without the new key', async () => {
|
|
mockPanel([plugin({
|
|
source_repo: 'acme/gotify', signed: true, keyFingerprint: null, version: '1.0.0',
|
|
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' },
|
|
})], []);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
// Nothing proves the block stale, so it must not be dropped.
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
// Neither fingerprint is knowable: no pinned key, and no registry entry to read the new one from.
|
|
expect(await screen.findAllByText('—')).toHaveLength(2);
|
|
expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-081: a key with no payload line has no fingerprint to show', async () => {
|
|
mockPanel([plugin({
|
|
source_repo: 'acme/gotify', keyFingerprint: 'OLDKEYaa…aaaaaaaa', version: '1.0.0',
|
|
updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'x', version: '2.0.0' },
|
|
})], [registryEntry({ authorPublicKey: 'untrusted comment: minisign public key' })]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Review' }));
|
|
expect(await screen.findByText('—')).toBeInTheDocument();
|
|
expect(screen.getByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-082: an outdated dependency is flagged amber just like a missing one', async () => {
|
|
mockPanel([plugin({
|
|
dependencies: { requiredAddons: [], pluginDependencies: [{ id: 'trek-base', version: '^2.0.0' }] },
|
|
dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [{ id: 'trek-base', wanted: '^2.0.0', installed: '1.0.0' }] },
|
|
})]);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Needs trek-base ^2.0.0')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-083: switching to Discover drops the installed-only sort key', async () => {
|
|
mockPanel([plugin()], [registryEntry()]);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Gotify');
|
|
|
|
fireEvent.click(screen.getByTitle('Sort: Name'));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Updates first' }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Updates first')).toBeInTheDocument());
|
|
|
|
fireEvent.click(screen.getByRole('tab', { name: /Discover/ }));
|
|
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-084: dropping an archive while the runtime is off installs nothing', async () => {
|
|
let uploaded = false;
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: false, plugins: [] })),
|
|
http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'x' }); }),
|
|
);
|
|
const { container } = render(<MAdminPluginsPanel />);
|
|
await screen.findByText('Plugins are disabled');
|
|
|
|
const panel = container.firstElementChild as HTMLElement;
|
|
fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [] } });
|
|
expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument();
|
|
|
|
fireEvent.drop(panel, { dataTransfer: { types: ['Files'], files: [new File(['zip'], 'p.zip')] } });
|
|
await waitFor(() => expect(screen.getByText('Plugins are disabled')).toBeInTheDocument());
|
|
expect(uploaded).toBe(false);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-085: an ordinary failure downloading a dependency falls back to the activation error', async () => {
|
|
mockPanel([plugin({ enabled: 0, status: 'inactive' })]);
|
|
server.use(
|
|
http.post('*/api/admin/plugins/trek-gotify/activate', () =>
|
|
HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })),
|
|
http.post('*/api/admin/plugins/install', () => HttpResponse.json({ error: 'registry unreachable' }, { status: 502 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: /Download/ }));
|
|
await waitFor(() => expect(toastMessages()).toContain('registry unreachable'));
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-086: every sheet closes from its own close button and from Escape', async () => {
|
|
mockPanel([plugin({ source_repo: 'acme/gotify', operatorEgress: true, egressHostCount: 1 })]);
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.json({ errors: [] })),
|
|
http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts: [] })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
// Row action sheet → its own close button.
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByText('View error log')).not.toBeInTheDocument());
|
|
|
|
// Error log → Escape, then the close button.
|
|
fireEvent.click(screen.getByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
await screen.findByText('No errors logged.');
|
|
await escapeUntilGone('No errors logged.');
|
|
|
|
// Allowed hosts → Escape, from the row chip.
|
|
fireEvent.click(screen.getByRole('button', { name: '1 allowed host(s)' }));
|
|
await screen.findByText('No hosts added yet.');
|
|
await escapeUntilGone('No hosts added yet.');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-087: the hosts sheet closes from its own close button too', async () => {
|
|
mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
|
HttpResponse.json({ supported: true, hosts: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' }));
|
|
await screen.findByText('No hosts added yet.');
|
|
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByText('No hosts added yet.')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-088: the error log sheet closes from its own close button too', async () => {
|
|
mockPanel([plugin()]);
|
|
server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.json({ errors: [] })));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
fireEvent.click(screen.getByText('View error log'));
|
|
await screen.findByText('No errors logged.');
|
|
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }));
|
|
await waitFor(() => expect(screen.queryByText('No errors logged.')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-090: an unreachable registry behind an installed list costs only the update badges', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [plugin({ version: '1.0.0' })] })),
|
|
http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'down' }, { status: 500 })),
|
|
);
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
expect(await screen.findByText('Gotify')).toBeInTheDocument();
|
|
expect(screen.queryByText(/updates available/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-091: a second tap on the toggle while the first is in flight is ignored', async () => {
|
|
let calls = 0;
|
|
let release: () => void = () => {};
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
mockPanel([plugin({ enabled: 0, status: 'inactive' })]);
|
|
server.use(http.post('*/api/admin/plugins/trek-gotify/activate', async () => {
|
|
calls += 1;
|
|
await gate;
|
|
return HttpResponse.json({ ok: true });
|
|
}));
|
|
render(<MAdminPluginsPanel />);
|
|
|
|
const toggle = await screen.findByRole('switch', { name: 'Enable plugin' });
|
|
fireEvent.click(toggle);
|
|
fireEvent.click(toggle);
|
|
release();
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Plugin activated'));
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-089: six-figure download counts round into millions', async () => {
|
|
mockPanel([], [registryEntry({ downloadCount: 1_200_000 })]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
|
|
|
expect(await screen.findByText('1.2M')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MAdminPluginsPanel — the security footer', () => {
|
|
it('FE-MOB-PLUGP-079: expands the containment explainer on demand', async () => {
|
|
mockPanel([]);
|
|
render(<MAdminPluginsPanel />);
|
|
await screen.findByText('No plugins installed yet.');
|
|
|
|
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByText('How plugins are contained — and the limits'));
|
|
|
|
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument();
|
|
expect(screen.getByText('The worst case')).toBeInTheDocument();
|
|
expect(screen.getByText('What "Signed" means')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByText('How plugins are contained — and the limits'));
|
|
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The admin-owned `scope:'instance'` settings sheet — the phone counterpart of the
|
|
* desktop modal. Same contract: gated on declared instance fields, secrets stay
|
|
* write-only (an untouched mask is never sent), and a save that restarted the
|
|
* running plugin must say so.
|
|
*/
|
|
describe('MAdminPluginsPanel — instance settings', () => {
|
|
const FIELDS = [
|
|
{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false },
|
|
{ key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true },
|
|
];
|
|
|
|
async function openRowSheet(p: Row) {
|
|
mockPanel([p]);
|
|
render(<MAdminPluginsPanel />);
|
|
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
|
|
}
|
|
|
|
async function openSettings() {
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' } })),
|
|
);
|
|
await openRowSheet(plugin({ instanceSettingsCount: 2 }));
|
|
fireEvent.click(screen.getByText('Instance settings'));
|
|
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 });
|
|
}
|
|
|
|
it('FE-MOB-PLUGP-CFG-001: a plugin with instance fields offers the sheet action, opening the form', async () => {
|
|
await openSettings();
|
|
expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password');
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-002: a plugin with NO instance fields gets no action', async () => {
|
|
await openRowSheet(plugin({ instanceSettingsCount: 0 }));
|
|
expect(screen.getByText('View error log')).toBeInTheDocument(); // the sheet is open
|
|
expect(screen.queryByText('Instance settings')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
|
body = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ config: body, restarted: false });
|
|
}),
|
|
);
|
|
await openSettings();
|
|
|
|
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Settings saved'), { timeout: 5000 });
|
|
expect(body).toEqual({ apiUrl: 'https://new.example' });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-004: a save that restarted the running plugin says so', async () => {
|
|
server.use(
|
|
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })),
|
|
);
|
|
await openSettings();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Settings saved — plugin restarted'), { timeout: 5000 });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-006: renders every declared field type and saves the edited values', async () => {
|
|
const RICH = [
|
|
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' },
|
|
{ key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in
|
|
{ key: 'retries', label: 'Retries', input_type: 'number', required: true },
|
|
];
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })),
|
|
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
|
body = await request.json() as Record<string, unknown>;
|
|
return HttpResponse.json({ config: body, restarted: false });
|
|
}),
|
|
);
|
|
await openRowSheet(plugin({ instanceSettingsCount: 3 }));
|
|
fireEvent.click(screen.getByText('Instance settings'));
|
|
|
|
const select = await screen.findByRole('combobox', {}, { timeout: 5000 });
|
|
expect(select).toHaveValue('slow');
|
|
expect(screen.getByRole('switch', { name: 'enabled' })).toBeInTheDocument(); // label falls back to the key
|
|
expect(screen.getByRole('spinbutton')).toHaveValue(null);
|
|
expect(screen.getByText('Pick one')).toBeInTheDocument(); // hint
|
|
|
|
fireEvent.change(select, { target: { value: 'fast' } });
|
|
fireEvent.click(screen.getByRole('switch', { name: 'enabled' }));
|
|
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Settings saved'), { timeout: 5000 });
|
|
expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-007: a failed config fetch is a toast, not a broken sheet', async () => {
|
|
server.use(
|
|
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
|
);
|
|
await openRowSheet(plugin({ instanceSettingsCount: 2 }));
|
|
fireEvent.click(screen.getByText('Instance settings'));
|
|
|
|
await waitFor(() => expect(toastMessages()).toContain('Error'), { timeout: 5000 });
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-008: a rejected save shows the server reason and keeps the sheet open', async () => {
|
|
server.use(
|
|
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
|
HttpResponse.json({ error: 'config refused' }, { status: 400 })),
|
|
);
|
|
await openSettings();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
|
|
|
expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument();
|
|
// Still open — the admin's edits are not thrown away on a refusal.
|
|
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-PLUGP-CFG-009: the sheet closes without saving', async () => {
|
|
await openSettings();
|
|
|
|
// Same retry shape as escapeUntilGone, but on the input value — an input's
|
|
// value is not text content, so the text-based helper would pass vacuously.
|
|
await waitFor(() => {
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|