mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321) * feat: tighter compact host rows with inline tags (#1325) * feat: bring back the alert feed homepage widget * feat: pick target terminals for snippets again * feat: share button on connection tabs again * feat: recent servers list when linking the desktop app * feat: list the fixed app shortcuts in keyboard shortcuts * fix: translate hardcoded toasts and drop em dashes from ui text * fix: translate leftover hardcoded ui text * fix: toolbar files button opens at the shell directory again * fix: show the remote desktop note under connection origin again * chore: openapi docs for host metrics manager routes * chore: openapi docs for undocumented routes and fix two broken doc blocks * fix: declare terminal.listSessions use for snippet targets * revert: drop the alert feed widget, the notification center replaces it * feat: restore the alert feed homepage widget * fix: quick connect fills the host address from plugin auth editors like tailscale * fix: docker container logs use the base background color * chore: fix dependabot alerts and apply safe npm upgrades * fix: proxmox stats tab reads its switch from plugin host settings * fix: count plugin ssh logins toward host status and stop showing reachable as a warning * fix: hide the built-in source pill in the host editor * fix: host status shows online when the port answers and only warns on a failed login * fix: drop the top border on the split layout menu * fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default * fix: remote desktop auth method labels and notes for vnc and telnet * fix: add description to the web endpoint enable switch * fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release * feat: move telemetry out of core into a usage statistics plugin * fix: recent activity card no longer cuts off host names early * fix: remove duplicate top border on split screen menu * fix: redesign session log player controls, fix tooltip overlap and drop dot separators * fix: file manager and sftp text showing raw i18n keys * fix: onboarding rerunning for existing users after upgrading * fix: fleet detail buttons overflowing the sidebar * fix: session log player scrolling off screen horizontally * fix: network graph lag when adding hosts and on every render * fix: maintenance index name, shared host read only view and badge polling * fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag * chore: update release notes * fix: remote desktop tabs reconnecting on every tab switch * fix: build plugin sdk before linting in release workflow * chore: lint, format, and bump version to 2.9.0 * chore: skip crowdin pre-translate in release for now * chore: switch to calver, bump version to 26.10.0 * fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * feat(ai): add remote coding agent sessions for SSH hosts (#1545) * feat(ai): add host coding agent sessions with configured providers * Wait for agent initialization and shutdown before resuming sessions * Keep Pi transcripts outside its configuration migration directory * Cover Pi session directory separation * Update core permission catalog expectations for remote agents * Install remote agent runtimes from verified official sources * Wait for the identity panel credentials request in its test * Add scoped SSH forwarding setup for remote agents * Verify forwarding setup through a fresh SSH connection * Bound the SSH forwarding verification timeout * Return the forwarding verification callback result * fix(ai): support agent send and interrupt keyboard shortcuts * feat(ai): add agent queues attachments worktrees and session recovery * fix(ai): handle deleted workspace paths and duplicate status events * fix(ai): accept file references in root workspaces * fix(ai): refresh paused queues after agent errors * fix(ai): store agent sessions in plugin tables and lock down OpenCode Agent sessions lived in a single ctx.kv value holding up to 2000 events, which passed the 256 KB kv limit within minutes of a normal run. Every save after that failed, so history, nativeId, queue and archive changes were lost and every event wrote a kv audit row. Sessions, events and queued prompts now have their own tables. A running agent appends events in batches at most once a second with a lazy save; session and queue changes save immediately. Prompts, drafts and stored event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no longer requested. The OpenCode server now requires a per-session password, so other local accounts on the host cannot drive the agent through its loopback port. * test(upgrade): include agent session tables in the reviewed upgrade SQL * test: run the posix-only ai agent tests on linux only * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 * fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi * chore: rework plugin readmes, json changelogs and standalone repo setup * chore: move plugins to their own repos and drop the plugins folder * chore(deps-dev): bump globals (#1560) Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals). Updates `globals` from 17.12.0 to 17.13.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: drop the 2.8 columns, moves and aliases kept for a downgrade * chore: remove the 2.9 finish list * feat: port the demo redesign with Manage and Settings tabs * chore: pin the plugin signing key and use TERMIX_PAT in workflows * chore: rename the plugin sdk to @termix-ssh/plugin-sdk * feat: add a publish workflow for the plugin sdk * chore: cleanup * fix: harden plugin sockets, public routes, prebuilt binaries and login fallback * chore: add a 2.9.1 upgrade check for postgres and mysql * fix: stop full database rewrites on every plugin db call * fix: stop full database rewrites on every plugin db call * fix: restore release video link in 2.9.2 notes * fix: use TERMIX_PAT in workflows * feat: pin the official plugins in the docker image * feat: implement plugin management UI * feat: shared list kit, settings search, rail order and ui consistency fixes * chore: run all workflows on blacksmith runners * chore: split dashboard quick actions into quarters and drop host status row lines * fix: open plain settings from the profile button, navigation only from the rail menu * feat: consent check, developer mode file installs, install counts and issue links for plugins * feat: row detail options for hosts and credentials, open manage from sidebar header * fix: unify connection failure screens and rebuild stale local plugins * feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev * chore: clean up npm scripts, make npm run dev the full runner and drop concurrently * fix: settle quick connect panel loads in tests so they stop flaking * fix: stop the dev runner rebuilding plugins when windows reports file reads as changes * chore: update the plugin sdk ui export snapshot * feat: full screen onboarding with a plugin picker and per step versions * feat: changelog.md release notes, v tags, plugin release notes and videos * chore: add temp changelog * chore: release the plugin sdk as 1.0.1 * feat: show only the latest plugin release notes and cap long ones * chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub * chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN * chore: renew mac app store provisioning profile * chore: drop docs job from release workflow * chore: run sdk publish on github hosted runner * chore: remove dependabot * chore: general file cleanup * chore: rewrite readme and taglines for plugin-based termix * chore: drop optional from ai plugin row in readme * feat: per-repo issue forms and prefilled bug reports instead of the support repo * feat: add report a bug link to the your account settings section * fix: open settings goes to the plugin's page and hides when it has none * feat: show plugin features from the manifest on the plugin page * fix: retry failed or blocked plugins on restart instead of leaving them disabled * fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling * feat: request a feature link for plugins and use the bug form for unverified plugins * feat: beta channels for core and plugins with one stable/beta release workflow * fix(sync): resolve shared hosts for connect-only recipients (#1568) * fix(ssh): accept full passwords in keyboard-interactive prompts (#1569) * fix(ssh): normalize PKCS8 private keys before connecting (#1570) * fix(sidebar): reclaim space from hidden folder actions (#1571) * fix(hosts): display protocol credential usernames for non-SSH hosts (#1572) * fix: ask for a push/TOTP menu choice as text instead of a push confirm * fix: keep client tunnels open when one of their connections fails (Support#1417) * fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420) * fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) * fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) * fix: refresh cached plugin translations when only a translation changes (Support#1411) * fix: import sshpk as a default so the backend starts under ESM * feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2 * fix: drop the ./ from the plugin sdk bin path so npm stops warning * chore: name core and sdk releases Termix vX and Plugin SDK vX * fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) * fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) * fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) * fix: refuse sessionless tokens and keep the secrets file owner only * feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) * chore: standardize repo setup files * chore: remove unused knip config * chore: add LICENSE and use Node 24 from .nvmrc * chore: update translation guidance, only en.json needs editing * feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 * fix: header separators render as borders and center the status divider * fix: lighter confirm prompt overlay to stop lag when it opens * fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 * fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK * fix: keep plugin tailwind variants in core's layer so overrides keep their order * fix: manage tab refetches before giving up on a host or credential it cannot find * fix: tab buttons like share show as soon as the terminal is ready * fix: folder rows no longer nest action buttons inside a button * fix: repin remote-desktop after its 1.0.0 re-release * chore: repin bundled plugins after the 1.0.0 re-release * fix: recent activity rows use the same separator as host status * feat: upload banner * fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24 * fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks * fix: bundled plugins path for source runs and backend cleanup * fix: load plugin strings on guest pages, plugin wording and drop unused keys * fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms * docs: 26.10.0 changelog * fix: current version in the version route example * chore: stop pushing the release- docker tag * feat: remove the donation reminder and its column * docs: note the removed donation reminder * fix: link server wizard always opens inline, onboarding card grows for it * feat: plugin text columns are longtext on mysql so they are not capped at 64KB * fix: linked desktops keep a plugin when its update from the server fails * chore: default mobile download links to v tags * fix: sso only accounts cannot sign in with a passkey or other local method * feat: note in the plugins tab that community plugins are coming * fix: plugin validate accepts beta versions * chore: pin bundled plugins rebuilt on sdk 1.0.5 * fix: skip locale fetches for disabled plugins * fix: show translated names for the built-in admin and user roles * fix: show host keys as SHA256 fingerprints like ssh-keygen * fix: host editor scrolls to a newly enabled protocol section * fix: pass the host to dashboard host row plugins * fix: plugin cards show dividers between author, version and category * fix: refuse plugin archives with paths outside the plugin folder * fix: theme picker shows one dropdown arrow * fix: read activity and audit times as UTC so they show in local time * fix: show the real error for a wrong password or code on plugin sign in * fix: explain that external sign in needs Auto-create external accounts * fix: clear leftover plugin unpack folders at startup * fix: shared session links open for guests again * fix: share panels no longer list yourself as a share target * fix: a host update without an auth type keeps the saved password * fix: guest pages get the tab ids a guest plugin registers * fix: refuse a 2.8 database, which already had a plugins table * fix: dashboard cards fit on a phone * fix: desktop setup no longer asks to secure the local account * fix: the desktop app keeps the desktop layout in a narrow window * fix: the desktop app shows the rail and no phone bar in a narrow window * fix: name every sync type in the Sync panel and pluralize the link prompt * chore: update the sdk ui snapshot for the host key dialog * fix: turning a plugin back on restarts the plugins it turned off * fix: quick connect asks to trust the host key * fix: revoking a manager also removes the shares they handed out * fix: database export works for users with TOTP on (#1426) * fix: rdp and vnc logins use the folder credential when none is picked (#1429) * fix: imported hosts get status checks without a restart * fix: re-pin bundled plugins after 1.0.0 re-release * fix: re-pin bundled plugins after search bar fix * chore: update app icon to smaller version * chore: round the readme logo, keep square source for app icons * feat: grid columns and table view in the plugins tab * fix: match the host key copy button to the fingerprint height * fix: use brand color for toggles in sync settings * fix: re-pin bundled plugins after 1.0.0 re-release * fix: web terminals failing auth behind a proxy that rewrites Host (Support#651) * fix: re-pin snippets and termix-identity after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * chore: remove Crowdin from the release workflow * chore: remove crowdin config --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
3656 files changed
+116210
-460999
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import {
|
||||
readArtifactFile,
|
||||
readArtifactManifest,
|
||||
sameCapabilities,
|
||||
} from "../../plugins/artifact-manifest.js";
|
||||
|
||||
function pack(files: Record<string, string>, prefix = ""): Buffer {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-artifact-"));
|
||||
try {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
const file = path.join(dir, "out.tmxplug");
|
||||
tar.c(
|
||||
{ gzip: true, sync: true, file, cwd: dir },
|
||||
Object.keys(files).map((name) => `${prefix}${name}`),
|
||||
);
|
||||
return fs.readFileSync(file);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("readArtifactFile", () => {
|
||||
it("finds a file by name, with or without a ./ prefix", () => {
|
||||
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
|
||||
expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x");
|
||||
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
|
||||
expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe(
|
||||
'{"id":"a"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for a missing file", () => {
|
||||
expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readArtifactManifest", () => {
|
||||
it("parses the manifest", () => {
|
||||
expect(
|
||||
readArtifactManifest(pack({ "manifest.json": '{"id":"notes"}' })),
|
||||
).toEqual({ id: "notes" });
|
||||
});
|
||||
|
||||
it("refuses a file that is not an archive", () => {
|
||||
expect(() => readArtifactManifest(Buffer.from("nope"))).toThrow(
|
||||
/not a valid .tmxplug/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an archive with no manifest or a broken one", () => {
|
||||
expect(() => readArtifactManifest(pack({ "a.txt": "a" }))).toThrow(
|
||||
/no manifest.json/,
|
||||
);
|
||||
expect(() =>
|
||||
readArtifactManifest(pack({ "manifest.json": "{oops" })),
|
||||
).toThrow(/not valid JSON/);
|
||||
expect(() =>
|
||||
readArtifactManifest(pack({ "manifest.json": "[1]" })),
|
||||
).toThrow(/not an object/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameCapabilities", () => {
|
||||
it("compares as sets", () => {
|
||||
expect(sameCapabilities(["a", "b"], ["b", "a"])).toBe(true);
|
||||
expect(sameCapabilities(["a"], ["a", "b"])).toBe(false);
|
||||
expect(sameCapabilities(["a", "c"], ["a", "b"])).toBe(false);
|
||||
expect(sameCapabilities([], [])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
let root = "";
|
||||
let plugin: { id: string; dir: string; manifest: never };
|
||||
let installed: { id: string; dir: string; manifest: never };
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
|
||||
@@ -32,6 +33,14 @@ beforeAll(async () => {
|
||||
write("locales/translated/de_DE.json", "{}");
|
||||
write("manifest.json", "{}");
|
||||
fs.writeFileSync(path.join(root, "outside.js"), "nope");
|
||||
const unpacked = path.join(root, ".unpacked", "gadget");
|
||||
fs.mkdirSync(path.join(unpacked, "dist"), { recursive: true });
|
||||
fs.writeFileSync(path.join(unpacked, "dist", "frontend.js"), "export {}");
|
||||
installed = {
|
||||
id: "gadget",
|
||||
dir: unpacked,
|
||||
manifest: { id: "gadget", version: "1.0.0" } as never,
|
||||
};
|
||||
plugin = {
|
||||
id: "gizmo",
|
||||
dir: path.join(root, "gizmo"),
|
||||
@@ -41,7 +50,9 @@ beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
|
||||
createPluginAssetsRouter((id) =>
|
||||
id === "gizmo" ? plugin : id === "gadget" ? installed : undefined,
|
||||
),
|
||||
);
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
@@ -74,6 +85,23 @@ describe("describePluginFrontend", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("changes the version when only a translation changes", () => {
|
||||
const before = describePluginFrontend(plugin).assetVersion;
|
||||
const locale = path.join(
|
||||
root,
|
||||
"gizmo",
|
||||
"locales",
|
||||
"translated",
|
||||
"de_DE.json",
|
||||
);
|
||||
fs.writeFileSync(locale, '{"hello":"Hallo"}');
|
||||
try {
|
||||
expect(describePluginFrontend(plugin).assetVersion).not.toBe(before);
|
||||
} finally {
|
||||
fs.writeFileSync(locale, "{}");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports nothing for an unknown plugin", () => {
|
||||
expect(describePluginFrontend(undefined)).toEqual({
|
||||
frontend: false,
|
||||
@@ -111,6 +139,12 @@ describe("resolvePluginAsset", () => {
|
||||
});
|
||||
|
||||
describe("GET /plugin-assets", () => {
|
||||
it("serves an installed plugin unpacked under a dot folder", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gadget/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("export {}");
|
||||
});
|
||||
|
||||
it("serves a bundle as a module any origin can load", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const manage = vi.hoisted(() => ({
|
||||
isLinkedDesktop: vi.fn(async () => false),
|
||||
updateAllPlugins: vi.fn(),
|
||||
}));
|
||||
const notify = vi.hoisted(() => ({ sendCoreAlert: vi.fn(async () => {}) }));
|
||||
|
||||
vi.mock("../../plugins/manage.js", () => manage);
|
||||
vi.mock("../../notify/core-notify.js", () => notify);
|
||||
|
||||
import { runPluginAutoUpdate } from "../../plugins/auto-update.js";
|
||||
|
||||
beforeEach(() => {
|
||||
manage.isLinkedDesktop.mockResolvedValue(false);
|
||||
manage.updateAllPlugins.mockReset();
|
||||
notify.sendCoreAlert.mockClear();
|
||||
});
|
||||
|
||||
describe("runPluginAutoUpdate", () => {
|
||||
it("only touches plugins with auto-update on", async () => {
|
||||
manage.updateAllPlugins.mockResolvedValue({
|
||||
updated: [{ id: "docker", version: "1.1.0" }],
|
||||
needsReview: [],
|
||||
failed: [],
|
||||
});
|
||||
await runPluginAutoUpdate();
|
||||
expect(manage.updateAllPlugins).toHaveBeenCalledWith({
|
||||
userId: null,
|
||||
onlyAutoUpdate: true,
|
||||
});
|
||||
expect(notify.sendCoreAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("alerts admins about an update that asks for more", async () => {
|
||||
manage.updateAllPlugins.mockResolvedValue({
|
||||
updated: [],
|
||||
needsReview: [{ id: "docker", capabilities: ["notify:send"] }],
|
||||
failed: [],
|
||||
});
|
||||
await runPluginAutoUpdate();
|
||||
expect(notify.sendCoreAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audience: "admins",
|
||||
dedupeKey: "plugin-review:docker:notify:send",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing on a linked desktop", async () => {
|
||||
manage.isLinkedDesktop.mockResolvedValue(true);
|
||||
await runPluginAutoUpdate();
|
||||
expect(manage.updateAllPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startablePluginIds } from "../../plugins/boot-state.js";
|
||||
|
||||
describe("startablePluginIds", () => {
|
||||
it("starts enabled plugins and retries ones that failed or were blocked", () => {
|
||||
const ids = startablePluginIds([
|
||||
{ id: "a", state: "enabled" },
|
||||
{ id: "b", state: "failed" },
|
||||
{ id: "c", state: "blocked" },
|
||||
{ id: "d", state: "disabled" },
|
||||
{ id: "e", state: "pending" },
|
||||
]);
|
||||
expect([...ids].sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Boots core the way starter.ts does, against a real SQLite database that
|
||||
* starts in 2.8 shape, with the plugin loader pointed at the built bundles in
|
||||
* dist/plugins rather than source. Shared by the boot and upgrade tests.
|
||||
*
|
||||
* Needs `npm run build` (or build:sdk and build:plugins) first.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { DATA_DIR_TOKEN } from "../fixtures/upgrade/rows.js";
|
||||
import { remoteTestDialect, seedRemote28 } from "./remote-seed.js";
|
||||
|
||||
export const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins");
|
||||
const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql");
|
||||
/** The committed 2.8 install: db.sqlite, the fixture.env it was keyed with and its files. */
|
||||
export const UPGRADE_FIXTURE_DIR = path.join(__dirname, "../fixtures/upgrade");
|
||||
|
||||
/** Every plugin in the repo, by folder. */
|
||||
export function sourcePluginIds(): string[] {
|
||||
const root = path.join(REPO_ROOT, "plugins");
|
||||
return fs
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(root, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Throws with the fix when dist/plugins is missing a plugin. */
|
||||
export function assertPluginsBuilt(): void {
|
||||
const missing = sourcePluginIds().filter(
|
||||
(id) =>
|
||||
!fs.existsSync(path.join(BUILT_PLUGINS_DIR, id, "dist", "backend.js")),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function builtManifest(id: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUILT_PLUGINS_DIR, id, "manifest.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** A 2.8 database in memory, with a helper that ignores unknown columns. */
|
||||
export function create28Database() {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(fs.readFileSync(SCHEMA_28, "utf8"));
|
||||
const columns = new Map<string, Set<string>>();
|
||||
const insert = (table: string, row: Row): number => {
|
||||
let known = columns.get(table);
|
||||
if (!known) {
|
||||
known = new Set(
|
||||
(
|
||||
sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((column) => column.name),
|
||||
);
|
||||
columns.set(table, known);
|
||||
}
|
||||
const entries = Object.entries(row).filter(([key]) => known!.has(key));
|
||||
const sql = `INSERT INTO "${table}" (${entries
|
||||
.map(([key]) => `"${key}"`)
|
||||
.join(", ")}) VALUES (${entries.map(() => "?").join(", ")})`;
|
||||
const values = entries.map(([, value]) =>
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
? 1
|
||||
: 0
|
||||
: value !== null && typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: value,
|
||||
);
|
||||
return Number(sqlite.prepare(sql).run(...values).lastInsertRowid);
|
||||
};
|
||||
return { sqlite, insert };
|
||||
}
|
||||
|
||||
export interface BootedCore {
|
||||
dataDir: string;
|
||||
/** The live database core and the plugins write to; null on Postgres and MySQL. */
|
||||
sqlite: Database.Database | null;
|
||||
/** The 2.8 database file exactly as it was written to DATA_DIR, if one was. */
|
||||
seed: Buffer | null;
|
||||
/** keepDataDir writes the database to disk and leaves DATA_DIR for another boot. */
|
||||
shutdown: (options?: { keepDataDir?: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
/** A 2.8 data directory to boot: a seeded database, or a folder holding one. */
|
||||
export type BootSource =
|
||||
Database.Database | { fixtureDir: string } | { reuseDataDir: string };
|
||||
|
||||
/**
|
||||
* Lays a committed 2.8 install (fixtures/upgrade) into DATA_DIR: its key file
|
||||
* and the files its rows point at, with the fixture's DATA_DIR token in those
|
||||
* rows swapped for the real path, as 2.8 stored them absolute.
|
||||
*/
|
||||
function placeFixture(fixtureDir: string, dataDir: string): Buffer {
|
||||
fs.copyFileSync(
|
||||
path.join(fixtureDir, "fixture.env"),
|
||||
path.join(dataDir, ".env"),
|
||||
);
|
||||
fs.cpSync(path.join(fixtureDir, "files"), dataDir, { recursive: true });
|
||||
|
||||
const sqlite = new Database(
|
||||
fs.readFileSync(path.join(fixtureDir, "db.sqlite")),
|
||||
);
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
"UPDATE session_recordings SET recording_path = replace(recording_path, ?, ?)",
|
||||
)
|
||||
.run(DATA_DIR_TOKEN, dataDir.split(path.sep).join("/"));
|
||||
return sqlite.serialize();
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEM_SECRETS = [
|
||||
"JWT_SECRET",
|
||||
"DATABASE_KEY",
|
||||
"ENCRYPTION_KEY",
|
||||
"INTERNAL_AUTH_TOKEN",
|
||||
];
|
||||
|
||||
/**
|
||||
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
|
||||
* sequence up to, not including, opening the HTTP server: the pre-upgrade
|
||||
* backup, the database, core's migrations, then the plugins and their data
|
||||
* moves. Call vi.resetModules() before, so module state from other tests is
|
||||
* gone.
|
||||
*/
|
||||
export async function bootCore(source: BootSource): Promise<BootedCore> {
|
||||
const dataDir =
|
||||
"reuseDataDir" in source
|
||||
? source.reuseDataDir
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR;
|
||||
// SystemCrypto prefers these over DATA_DIR/.env, and a previous boot in
|
||||
// this worker set them.
|
||||
for (const key of SYSTEM_SECRETS) delete process.env[key];
|
||||
|
||||
let seed: Buffer | null = null;
|
||||
if (source instanceof Database) {
|
||||
seed = source.serialize();
|
||||
} else if ("fixtureDir" in source) {
|
||||
seed = placeFixture(source.fixtureDir, dataDir);
|
||||
}
|
||||
|
||||
// TEST_DIALECT=postgres|mysql puts the same 2.8 data on a real server.
|
||||
const remote = remoteTestDialect();
|
||||
if (remote) {
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) throw new Error(`TEST_DIALECT=${remote} needs TEST_DATABASE_URL`);
|
||||
process.env.DATABASE_DIALECT = remote;
|
||||
process.env.DATABASE_URL = url;
|
||||
if (seed) await seedRemote28(remote, url, seed);
|
||||
} else if (seed) {
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed);
|
||||
}
|
||||
|
||||
const { SystemCrypto } = await import("../../utils/system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeEncryptionKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
const { backupBeforeUpgrade, runCoreBootMigrations } =
|
||||
await import("../../boot.js");
|
||||
await backupBeforeUpgrade({ dataDir, version: "2.9.0-test" });
|
||||
|
||||
const db = await import("../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
await runCoreBootMigrations();
|
||||
|
||||
const { primeKnownPermissions } =
|
||||
await import("../../utils/known-permissions.js");
|
||||
await primeKnownPermissions();
|
||||
|
||||
const { initializePlugins, shutdownPlugins } =
|
||||
await import("../../plugins/index.js");
|
||||
await initializePlugins();
|
||||
|
||||
const { runPluginDataMigrations } =
|
||||
await import("../../upgrade/boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
|
||||
return {
|
||||
dataDir,
|
||||
sqlite: remote ? null : db.getSqlite(),
|
||||
seed,
|
||||
shutdown: async ({ keepDataDir = false } = {}) => {
|
||||
await shutdownPlugins();
|
||||
if (keepDataDir) {
|
||||
if (!remote) {
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, "db.sqlite"),
|
||||
db.getSqlite().serialize(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* The built plugins really load: every bundle in dist/plugins activates on a
|
||||
* database that started in 2.8 shape, in dependency order, with every
|
||||
* service it needs provided and nothing registered twice.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertPluginsBuilt,
|
||||
bootCore,
|
||||
builtManifest,
|
||||
create28Database,
|
||||
sourcePluginIds,
|
||||
type BootedCore,
|
||||
} from "./built-harness.js";
|
||||
|
||||
const BOOT_TIMEOUT = 180_000;
|
||||
|
||||
let core: BootedCore;
|
||||
const logged = { errors: [] as string[], warnings: [] as string[] };
|
||||
|
||||
beforeAll(async () => {
|
||||
assertPluginsBuilt();
|
||||
vi.resetModules();
|
||||
const { sqlite } = create28Database();
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
logged.errors.push(args.map(String).join(" "));
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation((...args) => {
|
||||
logged.warnings.push(args.map(String).join(" "));
|
||||
});
|
||||
try {
|
||||
core = await bootCore(sqlite);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await core?.shutdown();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
describe("the built plugins on a 2.8 database", () => {
|
||||
it("boots without logging an error", () => {
|
||||
expect(logged.errors).toEqual([]);
|
||||
if (process.env.SHOW_BOOT_WARNINGS) {
|
||||
console.log(logged.warnings.join("\n"));
|
||||
}
|
||||
});
|
||||
|
||||
it("activates every plugin, none failed or blocked", async () => {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const states = Object.fromEntries(
|
||||
getPluginRuntime()
|
||||
.loader.list()
|
||||
.map((plugin) => [plugin.id, plugin.state]),
|
||||
);
|
||||
expect(Object.keys(states).sort()).toEqual(sourcePluginIds());
|
||||
const notActive = Object.entries(states).filter(
|
||||
([, state]) => state !== "active",
|
||||
);
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every declared service a provider and meets every hard dependency", async () => {
|
||||
const { listServices } = await import("../../plugins/service-registry.js");
|
||||
const provided = new Set(
|
||||
listServices().map((service) => `${service.pluginId}:${service.service}`),
|
||||
);
|
||||
const ids = new Set(sourcePluginIds());
|
||||
|
||||
for (const id of ids) {
|
||||
const manifest = builtManifest(id) as {
|
||||
provides?: Array<{ service: string }>;
|
||||
requires?: Array<{ service: string; optional?: boolean }>;
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
for (const { service } of manifest.provides ?? []) {
|
||||
expect(provided, `${id} provides ${service}`).toContain(
|
||||
`${id}:${service}`,
|
||||
);
|
||||
}
|
||||
const available = new Set(
|
||||
listServices().map((service) => service.service),
|
||||
);
|
||||
for (const requirement of manifest.requires ?? []) {
|
||||
if (requirement.optional) continue;
|
||||
expect(available, `${id} requires ${requirement.service}`).toContain(
|
||||
requirement.service,
|
||||
);
|
||||
}
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
||||
expect(ids, `${id} depends on ${dependency}`).toContain(dependency);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("registers no route, socket or permission that clashes", async () => {
|
||||
const { listRegistrationConflicts } =
|
||||
await import("../../plugins/conflicts.js");
|
||||
expect(listRegistrationConflicts()).toEqual([]);
|
||||
|
||||
const { getRegisteredWsRoutes } = await import("../../plugins/ws.js");
|
||||
const sockets = getRegisteredWsRoutes();
|
||||
expect(new Set(sockets).size).toBe(sockets.length);
|
||||
|
||||
// Plugin routes live under /plugin-api/<id>/, so a clash is two plugins
|
||||
// claiming one id, which the loader refuses; a legacy path must start
|
||||
// with its own plugin id.
|
||||
for (const id of sourcePluginIds()) {
|
||||
const legacy =
|
||||
(
|
||||
builtManifest(id) as {
|
||||
contributes?: { http?: { legacyPaths?: string[] } };
|
||||
}
|
||||
).contributes?.http?.legacyPaths ?? [];
|
||||
for (const legacyPath of legacy) {
|
||||
expect(legacyPath.startsWith(`/${id}/`)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
const { getPermissionCatalog } =
|
||||
await import("../../utils/permission-catalog.js");
|
||||
const permissions = getPermissionCatalog().flatMap(
|
||||
(entry) => entry.permissions,
|
||||
);
|
||||
expect(new Set(permissions).size).toBe(permissions.length);
|
||||
});
|
||||
|
||||
it("records every plugin migration it ran", async () => {
|
||||
const recorded = core
|
||||
.sqlite!.prepare(
|
||||
"SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id",
|
||||
)
|
||||
.all() as Array<{ plugin_id: string; n: number }>;
|
||||
const byPlugin = new Map(recorded.map((row) => [row.plugin_id, row.n]));
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const { BUILT_PLUGINS_DIR } = await import("./built-harness.js");
|
||||
for (const id of sourcePluginIds()) {
|
||||
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
|
||||
const files = fs.existsSync(dir)
|
||||
? fs.readdirSync(dir).filter((name) => name.endsWith(".sql"))
|
||||
: [];
|
||||
expect(byPlugin.get(id) ?? 0, `${id} migrations`).toBe(files.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
initialPluginState,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-index-"));
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = dir;
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
delete process.env.TEST_SSO_CLIENT;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
describe("onboardingDefaults", () => {
|
||||
it("treats everything as recommended without an index", () => {
|
||||
expect(onboardingDefaults("anything")).toEqual({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads recommended and consent from the index", () => {
|
||||
writeIndex({
|
||||
kept: { onboarding: { recommended: true, consent: false } },
|
||||
ask: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
expect(onboardingDefaults("kept")).toEqual({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
});
|
||||
expect(onboardingDefaults("ask").consent).toBe(true);
|
||||
expect(onboardingDefaults("other")).toEqual({
|
||||
recommended: false,
|
||||
consent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("recommends desktop-only plugins in the desktop app only", () => {
|
||||
writeIndex({ desk: { onboarding: { recommended: "desktop" } } });
|
||||
expect(onboardingDefaults("desk").recommended).toBe(false);
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
expect(onboardingDefaults("desk").recommended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enabledByEnv", () => {
|
||||
it("recommends a plugin once its environment config is set", () => {
|
||||
writeIndex({
|
||||
sso: { onboarding: { enabledByEnv: ["TEST_SSO_CLIENT"] } },
|
||||
});
|
||||
expect(onboardingDefaults("sso").recommended).toBe(false);
|
||||
expect(initialPluginState("sso", "bundled", true)).toBe("disabled");
|
||||
process.env.TEST_SSO_CLIENT = " ";
|
||||
expect(onboardingDefaults("sso").recommended).toBe(false);
|
||||
process.env.TEST_SSO_CLIENT = "termix";
|
||||
expect(onboardingDefaults("sso").recommended).toBe(true);
|
||||
expect(initialPluginState("sso", "bundled", true)).toBe("enabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialPluginState", () => {
|
||||
beforeEach(() => {
|
||||
writeIndex({
|
||||
kept: { onboarding: { recommended: true } },
|
||||
ask: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("enables every bundled plugin on an existing install", () => {
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
|
||||
});
|
||||
|
||||
it("enables only recommended, non-consent plugins on a fresh install", () => {
|
||||
expect(initialPluginState("kept", "bundled", true)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", true)).toBe("disabled");
|
||||
expect(initialPluginState("other", "bundled", true)).toBe("disabled");
|
||||
});
|
||||
|
||||
it("never enables a plugin dropped into the data directory", () => {
|
||||
expect(initialPluginState("kept", "user", true)).toBe("disabled");
|
||||
expect(initialPluginState("kept", "user", false)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* The bundled plugins, as they ship.
|
||||
*
|
||||
* This reads plugins/ in the repo rather than a fixture: the manifests are
|
||||
* checked-in artifacts, and a typo in one would otherwise only show up when
|
||||
* the server tried to boot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getBundledPluginsDir } from "../../plugins/paths.js";
|
||||
import { parseManifest } from "../../plugins/manifest.js";
|
||||
import { isKnownCapability } from "@termix/plugin-sdk/capabilities";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const BUNDLED_DIR = getBundledPluginsDir();
|
||||
|
||||
function bundledIds(): string[] {
|
||||
return fs
|
||||
.readdirSync(BUNDLED_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json")));
|
||||
}
|
||||
|
||||
function readManifest(pluginId: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
const ids = bundledIds();
|
||||
|
||||
describe("bundled plugins", () => {
|
||||
it("finds the bundled plugin directory", () => {
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(ids)("%s has a manifest that parses", (id) => {
|
||||
const { manifest, errors } = parseManifest(readManifest(id));
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe(id);
|
||||
});
|
||||
|
||||
it.each(ids)("%s declares only catalog capabilities", (id) => {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
|
||||
for (const capability of manifest?.capabilities ?? []) {
|
||||
expect(isKnownCapability(capability)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ids)("%s ships the entry points its manifest names", (id) => {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
const dir = path.join(BUNDLED_DIR, id);
|
||||
|
||||
expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true);
|
||||
});
|
||||
|
||||
// process:transport-owner was the v1 marker for the in-process tier. There
|
||||
// is one tier now, so nothing should still be asking for it.
|
||||
it.each(ids)("%s does not declare a v1 permission", (id) => {
|
||||
const raw = readManifest(id);
|
||||
|
||||
expect(raw.permissions).toBeUndefined();
|
||||
expect(raw.sidecars).toBeUndefined();
|
||||
expect(JSON.stringify(raw)).not.toContain("transport-owner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("declared dependencies", () => {
|
||||
it("names a plugin that is actually bundled", () => {
|
||||
for (const id of ids) {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
for (const dependency of Object.keys(manifest?.dependencies ?? {})) {
|
||||
expect(ids).toContain(dependency);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ai reaches automations through the automations.access service, so it
|
||||
// keeps working when automations is off.
|
||||
it("keeps ai's use of automations optional", () => {
|
||||
if (!ids.includes("ai")) return;
|
||||
|
||||
const { manifest } = parseManifest(readManifest("ai"));
|
||||
|
||||
expect(manifest?.dependencies?.automations).toBeUndefined();
|
||||
expect(manifest?.optionalDependencies?.automations).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugins that own a port", () => {
|
||||
// Every one of these had its server created at module scope at some point.
|
||||
// The rule is that it belongs inside activate, or disable leaves the port
|
||||
// held and re-enable fails.
|
||||
it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])(
|
||||
"%s creates no listener at module scope",
|
||||
(id) => {
|
||||
if (!ids.includes(id)) return;
|
||||
|
||||
const entry = path.join(BUNDLED_DIR, id, "src", "backend", "index.ts");
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
const moduleScope = source.slice(
|
||||
0,
|
||||
source.indexOf("export async function activate"),
|
||||
);
|
||||
|
||||
expect(moduleScope).not.toMatch(/\.listen\(/);
|
||||
expect(moduleScope).not.toMatch(/new WebSocketServer/);
|
||||
},
|
||||
);
|
||||
|
||||
it("docker does not register a process signal handler", () => {
|
||||
if (!ids.includes("docker")) return;
|
||||
|
||||
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
|
||||
// every plugin's deactivate. A plugin calling process.exit() itself
|
||||
// skipped the rest of that sequence.
|
||||
for (const file of ["index.ts", "console.ts"]) {
|
||||
const candidate = path.join(
|
||||
BUNDLED_DIR,
|
||||
"docker",
|
||||
"src",
|
||||
"backend",
|
||||
file,
|
||||
);
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
|
||||
// Comments are stripped first: the rule is worth explaining in prose
|
||||
// right where it applies, and that prose names the thing it forbids.
|
||||
const code = fs
|
||||
.readFileSync(candidate, "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
|
||||
expect(code).not.toMatch(/process\.on\(\s*["']SIG/);
|
||||
expect(code).not.toMatch(/process\.exit\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ vi.mock("../../utils/audit-logger.js", () => ({
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string) {
|
||||
const manifest = {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
createAuthState,
|
||||
fakeAuthManager,
|
||||
@@ -53,7 +53,8 @@ vi.mock("../../utils/trusted-proxy-auth.js", () => ({
|
||||
isTrustedProxyAuthEnabled: () => h.trustedProxy,
|
||||
}));
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
@@ -170,10 +171,9 @@ function setup() {
|
||||
request.ip ?? "unknown",
|
||||
"4:bob",
|
||||
);
|
||||
throw new (await import("@termix/plugin-sdk/backend")).LoginMethodError(
|
||||
"Invalid username or password",
|
||||
401,
|
||||
);
|
||||
throw new (
|
||||
await import("@termix-ssh/plugin-sdk/backend")
|
||||
).LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
return {
|
||||
kind: "external",
|
||||
@@ -199,7 +199,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("a plugin's external identity", () => {
|
||||
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
|
||||
it("carries the role map and logout claims into core", async () => {
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
@@ -211,7 +211,6 @@ describe("a plugin's external identity", () => {
|
||||
expect(result.kind).toBe("session");
|
||||
|
||||
const [user] = [...h.state.users.values()];
|
||||
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
|
||||
);
|
||||
@@ -219,9 +218,7 @@ describe("a plugin's external identity", () => {
|
||||
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
expect.objectContaining({
|
||||
ssoProviderId: 3,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -238,29 +235,6 @@ describe("a plugin's external identity", () => {
|
||||
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
|
||||
expect(mapped.rateLimitUsername).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds a 2.8 user by the old identifier and links the identity", async () => {
|
||||
h.state.users.set("u-1", {
|
||||
id: "u-1",
|
||||
username: "Alice",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "sub-1",
|
||||
});
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
);
|
||||
await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "corp-sso",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(h.state.users.size).toBe(1);
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ userId: "u-1", providerId: "3" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth.completeRedirectLogin", () => {
|
||||
@@ -306,7 +280,7 @@ describe("the other ctx.auth helpers", () => {
|
||||
const { auth } = setup();
|
||||
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
|
||||
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
|
||||
ssoProviderId: 3,
|
||||
providerId: 3,
|
||||
sub: null,
|
||||
sid: "sid-1",
|
||||
});
|
||||
@@ -402,8 +376,13 @@ describe("form methods and the external second factor setting", () => {
|
||||
username: "Bob",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u-bob",
|
||||
providerId: "ldap:4",
|
||||
subject: "bob",
|
||||
email: null,
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("../../utils/audit-logger.js", () => ({
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
|
||||
@@ -119,7 +119,7 @@ import {
|
||||
requireSecretResolver,
|
||||
resetSecretResolverRegistryForTests,
|
||||
} from "../../hosts/connect/secret-resolver-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import ssh2 from "ssh2";
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
|
||||
@@ -156,6 +156,7 @@ const baseHost = {
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "sshuser",
|
||||
authType: "password",
|
||||
password: "ssh-secret",
|
||||
jumpHosts: JSON.stringify([{ hostId: 3 }]),
|
||||
};
|
||||
@@ -285,6 +286,26 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall back to an old password on a key host", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.hosts.set(7, { ...baseHost, authType: "key" });
|
||||
state.logins.set("7:spice", {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
secretFields: {},
|
||||
});
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
const target = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
);
|
||||
expect(target?.auth.password).toBe("");
|
||||
});
|
||||
|
||||
it("uses the stored credential in credential mode", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
state.logins.set("7:spice", {
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
resetSyncRegistry,
|
||||
listEntityTypes,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock("../../utils/electron-ipc-bridge.js", () => ({
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
|
||||
@@ -36,7 +36,8 @@ const h = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
@@ -204,7 +205,8 @@ vi.mock("../../hosts/delete-host.js", () => ({
|
||||
}));
|
||||
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
@@ -474,7 +476,7 @@ describe("ctx.hosts", () => {
|
||||
password: "secret",
|
||||
key: "PRIVATE",
|
||||
autostartPassword: "x",
|
||||
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
|
||||
sshOptions: '{"keepaliveInterval":5}',
|
||||
enableDocker: true,
|
||||
jumpHosts: "[]",
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PluginNotification,
|
||||
PluginNotifyHub,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
@@ -81,7 +81,7 @@ import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import { clearNotifyHub } from "../../plugins/notify-hub.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(
|
||||
capabilities: string[],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
@@ -40,7 +41,8 @@ vi.mock("../../plugins/index.js", () => ({
|
||||
}));
|
||||
|
||||
const { createPluginPlugins } = await import("../../plugins/ctx-plugins.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const manifest = (capabilities: string[]) =>
|
||||
({ id: "reader", capabilities }) as unknown as PluginManifest;
|
||||
|
||||
@@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const h = vi.hoisted(() => ({ granted: new Set<string>() }));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
@@ -23,7 +24,8 @@ vi.mock("../../plugins/permissions.js", async () => {
|
||||
|
||||
const { createPluginProcess } = await import("../../plugins/ctx-process.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const DECLARED = ["process:spawn", "network:outbound"];
|
||||
|
||||
@@ -111,7 +113,8 @@ describe("ctx.process.ensureBinary", () => {
|
||||
const contents = "#!/bin/sh\necho hi\n";
|
||||
|
||||
it("uses a prebuilt copy whose checksum matches, without downloading", async () => {
|
||||
const prebuilt = path.join(dir, "prebuilt-tool");
|
||||
await fs.mkdir(path.join(dir, "prebuilt"), { recursive: true });
|
||||
const prebuilt = path.join(dir, "prebuilt", "tool");
|
||||
await fs.writeFile(prebuilt, contents);
|
||||
const fetch = vi.fn();
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
@@ -127,6 +130,23 @@ describe("ctx.process.ensureBinary", () => {
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never hashes a prebuilt path that is not the binary itself", async () => {
|
||||
const other = path.join(dir, "secrets.db");
|
||||
await fs.writeFile(other, contents);
|
||||
await fs.mkdir(path.join(dir, "tool-dir", "tool"), { recursive: true });
|
||||
const fetch = vi.fn(async () => new Response(contents));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
const result = await proc.ensureBinary({
|
||||
name: "tool",
|
||||
version: "1",
|
||||
url: "https://example.test/tool",
|
||||
sha256: sha256(contents),
|
||||
prebuilt: [other, "relative/tool", path.join(dir, "tool-dir", "tool")],
|
||||
});
|
||||
expect(result).toBe(path.join(dir, "bin", "tool"));
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("downloads once, verifies it and reuses the copy", async () => {
|
||||
const fetch = vi.fn(async () => new Response(contents));
|
||||
const { process: proc } = create({ fetch: fetch as never });
|
||||
|
||||
@@ -75,7 +75,7 @@ vi.mock("../../utils/audit-logger.js", () => ({
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
grants.set("demo", capabilities);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
clearServiceRegistry,
|
||||
getRegistration,
|
||||
} from "../../plugins/service-registry.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(pluginId: string, names?: string[]) {
|
||||
const manifest = {
|
||||
|
||||
@@ -22,7 +22,8 @@ vi.mock("../../utils/logger.js", () => {
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
@@ -142,7 +143,8 @@ const { classifyKeyboardInteractive } =
|
||||
await import("../../hosts/connect/keyboard-interactive.js");
|
||||
const { getLoginMethod, getSecondFactor } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
|
||||
return {
|
||||
@@ -554,6 +556,30 @@ describe("ctx.auth", () => {
|
||||
).not.toBe("browser");
|
||||
});
|
||||
|
||||
it("asks for a push/TOTP menu choice as text and a press-enter confirm as a push", () => {
|
||||
const host = { id: 1, ip: "10.0.0.1", port: 22, username: "root" };
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{
|
||||
name: "",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
|
||||
},
|
||||
host,
|
||||
),
|
||||
).toEqual({ kind: "input", promptIndex: 0, isPush: false });
|
||||
expect(
|
||||
classifyKeyboardInteractive(
|
||||
{
|
||||
name: "",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Press enter to send push", echo: false }],
|
||||
},
|
||||
host,
|
||||
),
|
||||
).toEqual({ kind: "input", promptIndex: 0, isPush: true });
|
||||
});
|
||||
|
||||
it("refuses a keyboard-interactive handler that is not declared", () => {
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { keyboardInteractive: ["gate"] }),
|
||||
@@ -647,7 +673,7 @@ describe("ctx.auth", () => {
|
||||
it("refuses enrolment when core policy forbids second factors", async () => {
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
h.factors = [];
|
||||
const { LoginMethodError } = await import("@termix/plugin-sdk/backend");
|
||||
const { LoginMethodError } = await import("@termix-ssh/plugin-sdk/backend");
|
||||
h.policyError = new LoginMethodError("password login is off", 409);
|
||||
const auth = createPluginAuth({
|
||||
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
|
||||
|
||||
@@ -18,7 +18,8 @@ vi.mock("../../utils/logger.js", () => {
|
||||
return { pluginLogger: log, sshLogger: log, logger: log, authLogger: log };
|
||||
});
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
@@ -110,7 +111,7 @@ const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { registerSshAuthProvider, resetSshAuthRegistryForTests } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const { PluginCapabilityError, PluginSshInteractionError } =
|
||||
await import("@termix/plugin-sdk/backend");
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const ALL = ["ssh:connect", "credentials:use", "credentials:read"];
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ const h = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
@@ -56,7 +57,8 @@ vi.mock("../../tls/acme-challenges.js", () => ({
|
||||
|
||||
const { createPluginSystem } = await import("../../plugins/ctx-system.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
let audits: Array<{ action: string; success: boolean }>;
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { handleDevMessage } = await import("../../plugins/dev-reload.js");
|
||||
|
||||
describe("dev reload messages", () => {
|
||||
it("reloads the plugin and answers with its state", async () => {
|
||||
const send = vi.fn();
|
||||
const reload = vi.fn(async () => ({ version: "1.0.0", state: "active" }));
|
||||
await handleDevMessage(
|
||||
{ type: "plugin-reload", id: "snippets" },
|
||||
send,
|
||||
reload,
|
||||
);
|
||||
expect(reload).toHaveBeenCalledWith("snippets");
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
type: "plugin-reloaded",
|
||||
id: "snippets",
|
||||
state: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("answers with the error when the reload fails", async () => {
|
||||
const send = vi.fn();
|
||||
await handleDevMessage(
|
||||
{ type: "plugin-reload", id: "snippets" },
|
||||
send,
|
||||
async () => {
|
||||
throw new Error("bad manifest");
|
||||
},
|
||||
);
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
type: "plugin-reload-failed",
|
||||
id: "snippets",
|
||||
error: "bad manifest",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores other messages", async () => {
|
||||
const send = vi.fn();
|
||||
const reload = vi.fn();
|
||||
await handleDevMessage({ type: "shutdown" }, send, reload);
|
||||
await handleDevMessage({ type: "plugin-reload" }, send, reload);
|
||||
await handleDevMessage(null, send, reload);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,19 @@ vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_PORT = 39102;
|
||||
// A fixed port can land in a range Windows reserves for Hyper-V or Docker.
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const TEST_PORT = await freePort();
|
||||
|
||||
function isListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { moveWithRetry } from "../../plugins/fs-retry.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-move-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function locked(): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error("locked"), { code: "EPERM" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("moveWithRetry", () => {
|
||||
it("waits out a passing lock", async () => {
|
||||
const root = tempDir();
|
||||
fs.mkdirSync(path.join(root, "from"));
|
||||
const real = fs.promises.rename;
|
||||
const rename = vi
|
||||
.spyOn(fs.promises, "rename")
|
||||
.mockRejectedValueOnce(locked())
|
||||
.mockImplementation(real);
|
||||
|
||||
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1, 1]);
|
||||
|
||||
expect(rename).toHaveBeenCalledTimes(2);
|
||||
expect(fs.existsSync(path.join(root, "to"))).toBe(true);
|
||||
});
|
||||
|
||||
it("copies a folder that never unlocks", async () => {
|
||||
const root = tempDir();
|
||||
fs.mkdirSync(path.join(root, "from", "nested"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "from", "nested", "a.js"), "x");
|
||||
vi.spyOn(fs.promises, "rename").mockRejectedValue(locked());
|
||||
|
||||
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1]);
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(root, "to", "nested", "a.js"), "utf8"),
|
||||
).toBe("x");
|
||||
expect(fs.existsSync(path.join(root, "from"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry a real error", async () => {
|
||||
const root = tempDir();
|
||||
await expect(
|
||||
moveWithRetry(path.join(root, "missing"), path.join(root, "to"), [1]),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
isValidPackageName,
|
||||
buildSudoCommand,
|
||||
parseUpgradable,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
execCommand,
|
||||
} from "@termix-ssh/plugin-sdk/host-commands";
|
||||
|
||||
describe("parsePlatformProbe", () => {
|
||||
it("prefers dnf over yum when both are present", () => {
|
||||
@@ -114,3 +115,13 @@ describe("parseUpgradable", () => {
|
||||
expect(parseUpgradable(null, "anything")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("execCommand", () => {
|
||||
it("leaves the command out of the timeout error", async () => {
|
||||
const client = { exec: () => {} } as never;
|
||||
const command = buildSudoCommand("apt update", "hunter2");
|
||||
await expect(execCommand(client, command, 5)).rejects.toThrow(
|
||||
/^Command timeout after 5ms$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -234,6 +234,48 @@ describe("ctx.http authentication", () => {
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("only opens the methods the public route was registered for", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/webhook/:token"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.post("/webhook/:token", (_req, res) => res.json({ ok: true }));
|
||||
router.delete("/webhook/:token", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
const url = `${base}/plugin-api/sample-plugin/webhook/abc`;
|
||||
|
||||
expect((await fetch(url, { method: "POST" })).status).toBe(200);
|
||||
expect((await fetch(url)).status).toBe(401);
|
||||
expect((await fetch(url, { method: "PUT" })).status).toBe(401);
|
||||
});
|
||||
|
||||
it("does not open a literal route beside a public parameter", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/webhook/:token"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/webhook/config", (_req, res) => res.json({ secret: true }));
|
||||
router.post("/webhook/config", (_req, res) => res.json({ secret: true }));
|
||||
router.get("/webhook/:token", (_req, res) => res.json({ ok: true }));
|
||||
router.post("/webhook/:token", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
const root = `${base}/plugin-api/sample-plugin/webhook`;
|
||||
|
||||
expect((await fetch(`${root}/config`)).status).toBe(401);
|
||||
expect((await fetch(`${root}/config`, { method: "POST" })).status).toBe(
|
||||
401,
|
||||
);
|
||||
expect((await fetch(`${root}/abc`)).status).toBe(200);
|
||||
expect((await fetch(`${root}/abc`, { method: "HEAD" })).status).toBe(200);
|
||||
expect((await fetch(`${root}/config`, { headers: authed })).status).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("opens everything below a trailing /* and nothing beside it", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | {
|
||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||
},
|
||||
fail: false,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
fetchRegistryStats: async () => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
DOWNLOADS_SOURCE,
|
||||
TELEMETRY_SOURCE,
|
||||
preferredCount,
|
||||
readInstallCounts,
|
||||
syncInstallCounts,
|
||||
} = await import("../../plugins/install-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
});
|
||||
|
||||
describe("preferredCount", () => {
|
||||
it("prefers active installs and falls back to downloads", () => {
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
||||
count: 12,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncInstallCounts", () => {
|
||||
it("writes one row per plugin with its preferred source", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", { downloads: 50, activeInstalls: 20 }],
|
||||
["tunnels", { downloads: 8, activeInstalls: null }],
|
||||
]),
|
||||
};
|
||||
expect(await syncInstallCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
||||
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readInstallCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
||||
const counts = await readInstallCounts();
|
||||
expect(counts.get("docker")).toEqual({
|
||||
count: 20,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
import zlib from "node:zlib";
|
||||
import {
|
||||
PluginLoader,
|
||||
assertBundledUpdate,
|
||||
removeStaleStaging,
|
||||
unsafeArchivePath,
|
||||
} from "../../plugins/loader.js";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -484,3 +490,130 @@ describe("PluginLoader error budget", () => {
|
||||
expect(loader.get("sample-plugin")?.lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader uninstall and pins", () => {
|
||||
it("ignores the shipped copy of a bundled plugin an admin uninstalled", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({ id: "ssh-terminal", root: bundled });
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["ssh-terminal"]);
|
||||
expect(loader.bundledVersion("docker")).toBeUndefined();
|
||||
expect(loader.bundledIds().has("docker")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads a downloaded copy of an uninstalled bundled id as a normal plugin", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
const userPlugins = path.join(user, "plugins");
|
||||
fs.mkdirSync(userPlugins, { recursive: true });
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
createFixturePlugin({ id: "docker", root: userPlugins });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].source).toBe("user");
|
||||
});
|
||||
|
||||
it("loads the shipped copy again on reinstall", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
await loader.loadAll();
|
||||
const plugin = await loader.loadBundled("docker");
|
||||
|
||||
expect(plugin.source).toBe("bundled");
|
||||
expect(loader.get("docker")).toBe(plugin);
|
||||
});
|
||||
|
||||
it("refuses an older signed update unless that exact version is pinned", () => {
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0"),
|
||||
).toThrow(/not newer/);
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "0.9.0"),
|
||||
).toThrow(/not newer/);
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "1.0.0"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", undefined, "1.1.0", "1.0.0"),
|
||||
).toThrow(/signed/);
|
||||
});
|
||||
});
|
||||
|
||||
/** A gzipped tar with the given entry names, written by hand. */
|
||||
function rawTar(names: string[]): Buffer {
|
||||
const blocks: Buffer[] = [];
|
||||
for (const name of names) {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, 100);
|
||||
header.write("0000644\0", 100);
|
||||
header.write("00000000001\0", 124);
|
||||
header.write(" ", 148);
|
||||
header.write("0", 156);
|
||||
header.write("ustar\0", 257);
|
||||
header.write("00", 263);
|
||||
let sum = 0;
|
||||
for (const byte of header) sum += byte;
|
||||
header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
|
||||
blocks.push(header, Buffer.concat([Buffer.from("x"), Buffer.alloc(511)]));
|
||||
}
|
||||
blocks.push(Buffer.alloc(1024));
|
||||
return zlib.gzipSync(Buffer.concat(blocks));
|
||||
}
|
||||
|
||||
describe("unsafeArchivePath", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-unsafe-"));
|
||||
const write = (names: string[]) => {
|
||||
const file = path.join(dir, `${names.length}-${Math.random()}.tmxplug`);
|
||||
fs.writeFileSync(file, rawTar(names));
|
||||
return file;
|
||||
};
|
||||
|
||||
it("finds entries that escape the folder", async () => {
|
||||
expect(
|
||||
await unsafeArchivePath(write(["manifest.json", "../../etc/x"])),
|
||||
).toBe("../../etc/x");
|
||||
expect(await unsafeArchivePath(write(["/tmp/x"]))).not.toBeNull();
|
||||
});
|
||||
|
||||
it("passes a normal archive", async () => {
|
||||
expect(
|
||||
await unsafeArchivePath(write(["manifest.json", "dist/backend.js"])),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeStaleStaging", () => {
|
||||
it("removes unpack folders left by a crashed install", async () => {
|
||||
const data = fs.mkdtempSync(path.join(os.tmpdir(), "termix-staging-"));
|
||||
const previous = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = data;
|
||||
try {
|
||||
const unpacked = path.join(data, "plugins", ".unpacked");
|
||||
fs.mkdirSync(path.join(unpacked, ".staging-abc", "dist"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(path.join(unpacked, "serial"), { recursive: true });
|
||||
await removeStaleStaging();
|
||||
expect(fs.readdirSync(unpacked)).toEqual(["serial"]);
|
||||
} finally {
|
||||
process.env.DATA_DIR = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,858 @@
|
||||
/**
|
||||
* Install, update, uninstall and switching, against a real PluginLoader and
|
||||
* fixture plugins on disk. The database and the registry are faked.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
tier: string;
|
||||
source: string;
|
||||
state: string;
|
||||
lastError: string | null;
|
||||
registryId: string | null;
|
||||
autoUpdate: boolean;
|
||||
pinnedVersion: string | null;
|
||||
manifestJson: string;
|
||||
}
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
rows: new Map<string, Record<string, unknown>>(),
|
||||
settings: new Map<string, string>(),
|
||||
grants: [] as Array<{ pluginId: string; capability: string }>,
|
||||
}));
|
||||
|
||||
const registry = vi.hoisted(() => ({
|
||||
index: { registry: "official", name: "", plugins: [] as unknown[] },
|
||||
download: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
listAll: async () => [...db.rows.values()],
|
||||
findById: async (id: string) => db.rows.get(id) ?? null,
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = {
|
||||
registryId: null,
|
||||
lastError: null,
|
||||
autoUpdate: false,
|
||||
pinnedVersion: null,
|
||||
...input,
|
||||
};
|
||||
db.rows.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
update: async (id: string, input: Record<string, unknown>) => {
|
||||
const row = db.rows.get(id);
|
||||
if (!row) return null;
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) row[key] = value;
|
||||
}
|
||||
return row;
|
||||
},
|
||||
delete: async (id: string) => db.rows.delete(id),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => void db.settings.set(key, value),
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (id: string) =>
|
||||
db.grants.filter((g) => g.pluginId === id),
|
||||
grant: async (input: { pluginId: string; capability: string }) =>
|
||||
void db.grants.push(input),
|
||||
}),
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
countKeys: async () => 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/data.js", () => ({
|
||||
migratePlugin: async () => [],
|
||||
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
|
||||
describePluginTables: async () => [],
|
||||
registerTable: () => ({}),
|
||||
forgetTables: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
|
||||
downloadRelease: registry.download,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../plugins/index.js", async () => {
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const loader = new PluginLoader();
|
||||
const seed = async (plugins: LoadedPlugin[]) => {
|
||||
for (const plugin of plugins) {
|
||||
if (db.rows.has(plugin.id)) {
|
||||
Object.assign(db.rows.get(plugin.id)!, {
|
||||
version: plugin.manifest.version,
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
db.rows.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
tier: plugin.source === "bundled" ? "bundled" : "community",
|
||||
source: plugin.source,
|
||||
state: plugin.source === "bundled" ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
registryId: null,
|
||||
autoUpdate: false,
|
||||
pinnedVersion: null,
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
} satisfies Row);
|
||||
}
|
||||
};
|
||||
return {
|
||||
getPluginRuntime: () => ({ loader }),
|
||||
activatePlugin: (id: string) => loader.activate(id),
|
||||
deactivatePlugin: (id: string) => loader.deactivate(id),
|
||||
registerLoadedPlugins: seed,
|
||||
unloadPlugin: async (id: string) => {
|
||||
if (!loader.get(id)) return;
|
||||
await loader.deactivate(id);
|
||||
loader.forget(id);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const manage = await import("../../plugins/manage.js");
|
||||
|
||||
const roots: string[] = [];
|
||||
let bundled: string;
|
||||
let data: string;
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
const DEFAULT_CAPS = ["hosts:read", "kv:own"];
|
||||
|
||||
function registryEntry(
|
||||
id: string,
|
||||
versions: Array<{ version: string; capabilities?: string[] }>,
|
||||
prereleases: Array<{ version: string; capabilities?: string[] }> = [],
|
||||
) {
|
||||
const toEntry = (v: { version: string; capabilities?: string[] }) => ({
|
||||
version: v.version,
|
||||
api: "1",
|
||||
url: `https://example.com/${id}-${v.version}.tmxplug`,
|
||||
sha256: "a".repeat(64),
|
||||
signature: "sig",
|
||||
size: 1,
|
||||
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
|
||||
});
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
versions: versions.map(toEntry),
|
||||
prereleases: prereleases.map(toEntry),
|
||||
};
|
||||
}
|
||||
|
||||
/** A .tmxplug of a fixture plugin, standing in for a verified download. */
|
||||
async function stageArtifact(
|
||||
id: string,
|
||||
manifestOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const build = tempRoot("termix-build-");
|
||||
createFixturePlugin({ id, root: build, manifestOverrides });
|
||||
const staging = path.join(data, "plugins", ".downloads");
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
const file = path.join(staging, `${id}-staged.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, ["."]);
|
||||
return { file, signatureFile: `${file}.sig` };
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const { loader } = getPluginRuntime();
|
||||
await manage.applyStoredPluginChoices(loader);
|
||||
const loaded = await loader.loadAll();
|
||||
const { registerLoadedPlugins } = await import("../../plugins/index.js");
|
||||
await registerLoadedPlugins(loaded);
|
||||
for (const plugin of loaded) {
|
||||
if (db.rows.get(plugin.id)?.state === "enabled") {
|
||||
await loader.activate(plugin.id);
|
||||
}
|
||||
}
|
||||
return loader;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bundled = tempRoot("termix-bundled-");
|
||||
data = tempRoot("termix-data-");
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = data;
|
||||
db.rows.clear();
|
||||
db.settings.clear();
|
||||
db.grants.length = 0;
|
||||
registry.index.plugins = [];
|
||||
registry.download.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { loader } = getPluginRuntime();
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("uninstall and reinstall of a bundled plugin", () => {
|
||||
it("deletes it, keeps it gone after a restart, and downloads it again", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
let loader = await boot();
|
||||
expect(loader.get("docker")?.state).toBe("active");
|
||||
|
||||
await manage.uninstallPlugin("docker");
|
||||
expect(loader.get("docker")).toBeUndefined();
|
||||
expect(db.rows.has("docker")).toBe(false);
|
||||
expect(fs.existsSync(path.join(bundled, "docker"))).toBe(false);
|
||||
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
|
||||
"docker",
|
||||
]);
|
||||
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
// A recreated container brings the image's copy back; it stays ignored.
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
loader = await boot();
|
||||
expect(loader.get("docker")).toBeUndefined();
|
||||
|
||||
registry.download.mockImplementation(() => stageArtifact("docker"));
|
||||
const result = await manage.installPlugin("docker", {
|
||||
userId: "admin",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
expect(registry.download).toHaveBeenCalledOnce();
|
||||
expect(loader.get("docker")?.source).toBe("user");
|
||||
expect(fs.existsSync(path.join(data, "plugins", "docker.tmxplug"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(db.rows.get("docker")).toMatchObject({
|
||||
registryId: "official",
|
||||
tier: "official",
|
||||
});
|
||||
expect(db.grants.map((g) => g.capability)).toEqual([
|
||||
"hosts:read",
|
||||
"kv:own",
|
||||
]);
|
||||
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
loader = await boot();
|
||||
expect(loader.get("docker")?.source).toBe("user");
|
||||
});
|
||||
|
||||
it("deletes a plugin dropped in as a plain folder", async () => {
|
||||
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
||||
const loader = await boot();
|
||||
expect(loader.get("notes")?.source).toBe("user");
|
||||
|
||||
await manage.uninstallPlugin("notes");
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(data, "plugins"))).toBe(true);
|
||||
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to install something already installed", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("docker", { userId: "admin" }),
|
||||
).rejects.toMatchObject({ code: "ALREADY_INSTALLED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("installing from the registry", () => {
|
||||
it("puts the artifact in place, grants what it declares and starts it", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
registry.download.mockImplementation(() =>
|
||||
stageArtifact("notes", { capabilities: ["kv:own"] }),
|
||||
);
|
||||
const loader = await boot();
|
||||
|
||||
const result = await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ id: "notes", state: "active" });
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(loader.get("notes")?.source).toBe("user");
|
||||
expect(db.rows.get("notes")?.tier).toBe("official");
|
||||
expect(db.grants).toEqual([
|
||||
expect.objectContaining({ pluginId: "notes", capability: "kv:own" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a plugin that is not in the registry", async () => {
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("nope", { userId: "admin" }),
|
||||
).rejects.toMatchObject({ code: "NOT_IN_REGISTRY" });
|
||||
});
|
||||
|
||||
it("pins an explicitly picked older version", async () => {
|
||||
registry.index.plugins = [
|
||||
registryEntry("notes", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
];
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
|
||||
await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
version: "1.0.0",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("uninstalls a registry plugin and its files", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
|
||||
await manage.uninstallPlugin("notes");
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(db.rows.has("notes")).toBe(false);
|
||||
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updates", () => {
|
||||
it("waits for consent when a release asks for new capabilities", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [
|
||||
registryEntry("docker", [
|
||||
{
|
||||
version: "1.1.0",
|
||||
capabilities: ["hosts:read", "kv:own", "notify:send"],
|
||||
},
|
||||
{ version: "1.0.0" },
|
||||
]),
|
||||
];
|
||||
await boot();
|
||||
|
||||
await expect(
|
||||
manage.updatePlugin("docker", { userId: "admin" }),
|
||||
).rejects.toMatchObject({
|
||||
code: "CAPABILITIES_ADDED",
|
||||
details: { capabilities: ["notify:send"] },
|
||||
});
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists an update and skips it in update-all when pinned", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [
|
||||
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
];
|
||||
await boot();
|
||||
|
||||
const listing = await manage.listRegistry();
|
||||
expect(listing.plugins[0]).toMatchObject({
|
||||
installed: true,
|
||||
installedVersion: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
updateAvailable: true,
|
||||
addedCapabilities: [],
|
||||
bundled: true,
|
||||
});
|
||||
|
||||
await manage.setPluginOptions("docker", { pinned: true });
|
||||
const result = await manage.updateAllPlugins({ userId: "admin" });
|
||||
expect(result.updated).toEqual([]);
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("beta channel", () => {
|
||||
it("offers betas only to a plugin on the beta channel", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [
|
||||
registryEntry(
|
||||
"docker",
|
||||
[{ version: "1.0.0" }],
|
||||
[{ version: "1.1.0-beta.1" }],
|
||||
),
|
||||
];
|
||||
await boot();
|
||||
|
||||
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
||||
channel: "stable",
|
||||
latestVersion: "1.0.0",
|
||||
latestBeta: "1.1.0-beta.1",
|
||||
updateAvailable: false,
|
||||
});
|
||||
expect(
|
||||
(await manage.listRegistry()).plugins[0].versions.map((v) => [
|
||||
v.version,
|
||||
v.prerelease,
|
||||
]),
|
||||
).toEqual([
|
||||
["1.1.0-beta.1", true],
|
||||
["1.0.0", false],
|
||||
]);
|
||||
|
||||
await manage.setPluginOptions("docker", { channel: "beta" });
|
||||
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
||||
channel: "beta",
|
||||
latestVersion: "1.1.0-beta.1",
|
||||
updateAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an installed beta after going back to stable", async () => {
|
||||
createFixturePlugin({
|
||||
id: "docker",
|
||||
root: bundled,
|
||||
manifestOverrides: { version: "1.1.0-beta.1" },
|
||||
});
|
||||
registry.index.plugins = [
|
||||
registryEntry(
|
||||
"docker",
|
||||
[{ version: "1.0.0" }],
|
||||
[{ version: "1.1.0-beta.1" }],
|
||||
),
|
||||
];
|
||||
await boot();
|
||||
|
||||
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
||||
channel: "stable",
|
||||
installedVersion: "1.1.0-beta.1",
|
||||
updateAvailable: false,
|
||||
});
|
||||
const result = await manage.updateAllPlugins({ userId: "admin" });
|
||||
expect(result.updated).toEqual([]);
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
|
||||
registry.index.plugins = [
|
||||
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
||||
];
|
||||
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
||||
latestVersion: "1.1.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("installs the newest beta when asked to follow betas", async () => {
|
||||
registry.index.plugins = [
|
||||
registryEntry(
|
||||
"notes",
|
||||
[{ version: "1.0.0" }],
|
||||
[{ version: "1.1.0-beta.2" }],
|
||||
),
|
||||
];
|
||||
registry.download.mockImplementation(() =>
|
||||
stageArtifact("notes", { version: "1.1.0-beta.2" }),
|
||||
);
|
||||
await boot();
|
||||
|
||||
const result = await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
channel: "beta",
|
||||
capabilities: DEFAULT_CAPS,
|
||||
});
|
||||
expect(result.version).toBe("1.1.0-beta.2");
|
||||
expect(db.rows.get("notes")).toMatchObject({
|
||||
channel: "beta",
|
||||
pinnedVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves every installed plugin to one channel", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
createFixturePlugin({ id: "notes", root: bundled });
|
||||
await boot();
|
||||
|
||||
expect((await manage.setAllPluginChannels("beta")).changed.sort()).toEqual([
|
||||
"docker",
|
||||
"notes",
|
||||
]);
|
||||
expect(db.rows.get("docker")?.channel).toBe("beta");
|
||||
expect((await manage.setAllPluginChannels("beta")).changed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependencies", () => {
|
||||
it("starts a dependency first and stops dependents first", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
const loader = await boot();
|
||||
|
||||
const off = await manage.setPluginState("base", false);
|
||||
expect(off.disable).toEqual(["addon"]);
|
||||
expect(loader.get("addon")?.state).toBe("stopped");
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
|
||||
const plan = await manage.planStateChange("addon", true);
|
||||
expect(plan.enable).toEqual(["base"]);
|
||||
const on = await manage.setPluginState("addon", true);
|
||||
expect(on.state).toBe("active");
|
||||
expect(loader.get("base")?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("restarts dependents it stopped when turned back on", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
createFixturePlugin({
|
||||
id: "manual",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
const loader = await boot();
|
||||
|
||||
await manage.setPluginState("manual", false);
|
||||
await manage.setPluginState("base", false);
|
||||
expect(loader.get("addon")?.state).toBe("stopped");
|
||||
|
||||
await manage.setPluginState("base", true);
|
||||
expect(loader.get("addon")?.state).toBe("active");
|
||||
expect(db.rows.get("addon")).toMatchObject({
|
||||
state: "enabled",
|
||||
lastError: null,
|
||||
});
|
||||
expect(loader.get("manual")?.state).not.toBe("active");
|
||||
expect(db.rows.get("manual")?.state).toBe("disabled");
|
||||
});
|
||||
|
||||
it("refuses to uninstall a plugin a running one depends on", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
await boot();
|
||||
|
||||
await expect(manage.uninstallPlugin("base")).rejects.toMatchObject({
|
||||
code: "HAS_DEPENDENTS",
|
||||
details: { dependents: ["addon"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloading a bundled plugin for the dev runner", () => {
|
||||
it("loads the rebuilt copy and restarts what depends on it", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
const loader = await boot();
|
||||
createFixturePlugin({
|
||||
id: "base",
|
||||
root: bundled,
|
||||
manifestOverrides: { version: "1.1.0" },
|
||||
});
|
||||
|
||||
const result = await manage.reloadBundledPlugin("base");
|
||||
expect(result).toEqual({ id: "base", version: "1.1.0", state: "active" });
|
||||
expect(loader.get("base")?.manifest.version).toBe("1.1.0");
|
||||
expect(loader.get("addon")?.state).toBe("active");
|
||||
expect(db.rows.get("base")?.version).toBe("1.1.0");
|
||||
});
|
||||
|
||||
it("loads and starts a plugin added since boot", async () => {
|
||||
const loader = await boot();
|
||||
createFixturePlugin({ id: "fresh", root: bundled });
|
||||
const result = await manage.reloadBundledPlugin("fresh");
|
||||
expect(result.state).toBe("active");
|
||||
expect(loader.get("fresh")?.state).toBe("active");
|
||||
});
|
||||
|
||||
it("keeps a disabled plugin stopped", async () => {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
const loader = await boot();
|
||||
await manage.setPluginState("base", false);
|
||||
const result = await manage.reloadBundledPlugin("base");
|
||||
expect(result.state).toBe("stopped");
|
||||
expect(loader.get("base")?.state).toBe("loaded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consent", () => {
|
||||
it("refuses an install without the consented list, before any download", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("notes", { userId: "admin" }),
|
||||
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the consent does not match what the release asks for", async () => {
|
||||
registry.index.plugins = [
|
||||
registryEntry("notes", [
|
||||
{ version: "1.0.0", capabilities: ["kv:own", "credentials:read"] },
|
||||
]),
|
||||
];
|
||||
await boot();
|
||||
await expect(
|
||||
manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own"],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "CONSENT_MISMATCH",
|
||||
details: { capabilities: ["kv:own", "credentials:read"] },
|
||||
});
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
expect(db.rows.has("notes")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the same list in another order", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
registry.download.mockImplementation(() => stageArtifact("notes"));
|
||||
await boot();
|
||||
const result = await manage.installPlugin("notes", {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own", "hosts:read"],
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
});
|
||||
|
||||
it("needs the added capabilities listed to accept an update", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
registry.index.plugins = [
|
||||
registryEntry("docker", [
|
||||
{
|
||||
version: "1.1.0",
|
||||
capabilities: ["hosts:read", "kv:own", "notify:send"],
|
||||
},
|
||||
{ version: "1.0.0" },
|
||||
]),
|
||||
];
|
||||
await boot();
|
||||
|
||||
await expect(
|
||||
manage.updatePlugin("docker", {
|
||||
userId: "admin",
|
||||
acceptCapabilities: true,
|
||||
capabilities: ["ssh:connect"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||
await expect(
|
||||
manage.updatePlugin("docker", {
|
||||
userId: "admin",
|
||||
acceptCapabilities: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||
expect(registry.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("installing from a file", () => {
|
||||
async function artifactBuffer(
|
||||
id: string,
|
||||
manifestOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const staged = await stageArtifact(id, manifestOverrides);
|
||||
const buffer = fs.readFileSync(staged.file);
|
||||
fs.rmSync(staged.file);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
manage.resetUploads();
|
||||
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
});
|
||||
|
||||
it("is refused while developer mode is off", async () => {
|
||||
await boot();
|
||||
await expect(
|
||||
manage.stageUpload(await artifactBuffer("notes")),
|
||||
).rejects.toMatchObject({ code: "DEVELOPER_MODE_OFF" });
|
||||
});
|
||||
|
||||
it("is refused when signed plugins are required", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await expect(
|
||||
manage.stageUpload(await artifactBuffer("notes")),
|
||||
).rejects.toMatchObject({ code: "SIGNED_ONLY" });
|
||||
});
|
||||
|
||||
it("stages, asks for consent, then installs it marked unverified", async () => {
|
||||
const loader = await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
|
||||
const preview = await manage.stageUpload(
|
||||
await artifactBuffer("notes", { capabilities: ["kv:own"] }),
|
||||
);
|
||||
expect(preview).toMatchObject({
|
||||
id: "notes",
|
||||
capabilities: ["kv:own"],
|
||||
replaces: null,
|
||||
});
|
||||
expect(loader.get("notes")).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own", "hosts:read"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||
|
||||
const result = await manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own"],
|
||||
});
|
||||
expect(result.state).toBe("active");
|
||||
expect(db.rows.get("notes")).toMatchObject({
|
||||
tier: manage.UNVERIFIED_TIER,
|
||||
registryId: null,
|
||||
});
|
||||
expect(loader.get("notes")?.signedBy).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// A token is used once.
|
||||
await expect(
|
||||
manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: ["kv:own"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||
});
|
||||
|
||||
it("replaces an earlier upload but never a bundled plugin", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
const loader = await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
|
||||
await expect(
|
||||
manage.stageUpload(await artifactBuffer("docker")),
|
||||
).rejects.toMatchObject({ code: "BUNDLED_ID" });
|
||||
|
||||
const first = await manage.stageUpload(await artifactBuffer("notes"));
|
||||
await manage.installUpload(first.token, {
|
||||
userId: "admin",
|
||||
capabilities: first.capabilities,
|
||||
});
|
||||
const second = await manage.stageUpload(
|
||||
await artifactBuffer("notes", { version: "1.1.0" }),
|
||||
);
|
||||
expect(second.replaces).toBe("1.0.0");
|
||||
await manage.installUpload(second.token, {
|
||||
userId: "admin",
|
||||
capabilities: second.capabilities,
|
||||
});
|
||||
expect(loader.get("notes")?.manifest.version).toBe("1.1.0");
|
||||
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
||||
});
|
||||
|
||||
it("refuses a file that is not a plugin archive", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
await expect(
|
||||
manage.stageUpload(Buffer.from("not a tarball")),
|
||||
).rejects.toMatchObject({ code: "INVALID_ARTIFACT" });
|
||||
});
|
||||
|
||||
it("drops staged files when developer mode is turned off", async () => {
|
||||
await boot();
|
||||
await manage.setDeveloperMode(true);
|
||||
const preview = await manage.stageUpload(await artifactBuffer("notes"));
|
||||
await manage.setDeveloperMode(false);
|
||||
await manage.setDeveloperMode(true);
|
||||
await expect(
|
||||
manage.installUpload(preview.token, {
|
||||
userId: "admin",
|
||||
capabilities: preview.capabilities,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("release notes", () => {
|
||||
it("reads an installed plugin's CHANGELOG.md", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "docker", "CHANGELOG.md"),
|
||||
"# Changelog\n\n## 1.1.0 - 2026-10-06\n\n### Fixed\n- A bug\n\n## 1.0.0\n\n### Added\n- First\n",
|
||||
);
|
||||
await boot();
|
||||
expect(await manage.getPluginChangelog("docker")).toEqual([
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2026-10-06",
|
||||
changes: [{ type: "fixed", text: "A bug" }],
|
||||
},
|
||||
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is empty without a changelog and 404s for an unknown plugin", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
await boot();
|
||||
expect(await manage.getPluginChangelog("docker")).toEqual([]);
|
||||
await expect(manage.getPluginChangelog("nope")).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses registry notes and drops empty ones", () => {
|
||||
expect(manage.readReleaseNotes("Summary.\n\n### Added\n- Thing")).toEqual({
|
||||
summary: "Summary.",
|
||||
changes: [{ type: "added", text: "Thing" }],
|
||||
});
|
||||
expect(manage.readReleaseNotes("")).toBeUndefined();
|
||||
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
isTermixCompatible,
|
||||
youtubeVideoId,
|
||||
pluginFeatures,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
} from "../../plugins/manifest.js";
|
||||
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -119,8 +124,8 @@ describe("manifest v2 validation", () => {
|
||||
it("checks engine.termix against the core version", () => {
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true);
|
||||
expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true);
|
||||
expect(isTermixCompatible(">=3.0.0", "2.9.1")).toBe(false);
|
||||
expect(isTermixCompatible(">=3.0.0", null)).toBe(true);
|
||||
expect(isTermixCompatible(">=26.10.0", "2.9.1")).toBe(false);
|
||||
expect(isTermixCompatible(">=26.10.0", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fills in the entry point defaults", () => {
|
||||
@@ -852,3 +857,148 @@ describe("contributes.protocols", () => {
|
||||
expect(errors.join("\n")).toMatch(/extra/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifest video", () => {
|
||||
it("accepts YouTube links", () => {
|
||||
for (const video of [
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"https://youtube.com/shorts/dQw4w9WgXcQ",
|
||||
]) {
|
||||
expect(validateManifest(base({ video }))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects anything that is not a YouTube video", () => {
|
||||
for (const video of [
|
||||
"https://evil.example/watch?v=dQw4w9WgXcQ",
|
||||
"http://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://www.youtube.com.evil.example/watch?v=dQw4w9WgXcQ",
|
||||
"https://user@www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/watch?v=short",
|
||||
"https://www.youtube.com/channel/UC123",
|
||||
"javascript:alert(1)",
|
||||
"",
|
||||
42,
|
||||
]) {
|
||||
expect(validateManifest(base({ video }))).toEqual([
|
||||
expect.stringContaining('Field "video" must be a YouTube link'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifest features", () => {
|
||||
it("accepts a short list of lines", () => {
|
||||
expect(
|
||||
validateManifest(base({ features: ["Start containers", "Read logs"] })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects bad lists", () => {
|
||||
expect(validateManifest(base({ features: "Logs" }))).toEqual([
|
||||
'Field "features" must be an array of strings',
|
||||
]);
|
||||
expect(validateManifest(base({ features: ["ok", "", 4] }))).toHaveLength(2);
|
||||
expect(
|
||||
validateManifest(base({ features: ["x".repeat(161)] })).join(),
|
||||
).toMatch(/features\[0\]/);
|
||||
expect(
|
||||
validateManifest(base({ features: Array(21).fill("x") })).join(),
|
||||
).toMatch(/at most 20/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginFeatures", () => {
|
||||
it("keeps only usable lines", () => {
|
||||
expect(pluginFeatures([" Logs ", "", 3, "x".repeat(161), "Stats"])).toEqual(
|
||||
["Logs", "Stats"],
|
||||
);
|
||||
expect(pluginFeatures(Array(30).fill("x"))).toHaveLength(20);
|
||||
expect(pluginFeatures("Logs")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("youtubeVideoId", () => {
|
||||
it("returns only the id", () => {
|
||||
expect(
|
||||
youtubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42"),
|
||||
).toBe("dQw4w9WgXcQ");
|
||||
expect(youtubeVideoId("https://youtu.be/dQw4w9WgXcQ?si=abc")).toBe(
|
||||
"dQw4w9WgXcQ",
|
||||
);
|
||||
expect(youtubeVideoId("https://youtu.be/../../x")).toBeNull();
|
||||
expect(youtubeVideoId(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs and env fields", () => {
|
||||
it("accepts an https docs link and well formed env entries", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
docs: "https://docs.termix.site/plugins/sample-plugin",
|
||||
env: [
|
||||
{ name: "SAMPLE_URL", description: "Where to connect." },
|
||||
{
|
||||
name: "SAMPLE_TOKEN",
|
||||
description: "Token.",
|
||||
secret: true,
|
||||
required: true,
|
||||
default: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a docs link that is not https", () => {
|
||||
const errors = validateManifest(base({ docs: "http://example.com" }));
|
||||
expect(errors.join(" ")).toMatch(/"docs" must be an https URL/);
|
||||
});
|
||||
|
||||
it("refuses bad env entries", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
env: [
|
||||
{ name: "lower_case", description: "x" },
|
||||
{ name: "DUP", description: "x" },
|
||||
{ name: "DUP", description: "x" },
|
||||
{ name: "NO_DESC", description: "" },
|
||||
{ name: "EXTRA", description: "x", other: 1 },
|
||||
{ name: "FLAG", description: "x", secret: "yes" },
|
||||
],
|
||||
}),
|
||||
).join(" ");
|
||||
expect(errors).toMatch(/env\[0\]\.name/);
|
||||
expect(errors).toMatch(/env\[2\]\.name" repeats DUP/);
|
||||
expect(errors).toMatch(/env\[3\]\.description/);
|
||||
expect(errors).toMatch(/Unknown field "other" in env\[4\]/);
|
||||
expect(errors).toMatch(/env\[5\]\.secret/);
|
||||
});
|
||||
|
||||
it("reads docs links and env entries leniently", () => {
|
||||
expect(pluginDocsUrl("https://docs.termix.site/plugins/x/")).toBe(
|
||||
"https://docs.termix.site/plugins/x",
|
||||
);
|
||||
expect(pluginDocsUrl("file:///etc/passwd")).toBeNull();
|
||||
expect(pluginDocsUrl(42)).toBeNull();
|
||||
expect(
|
||||
pluginEnvVars([
|
||||
{ name: "OK_VAR", description: "fine" },
|
||||
{ name: "bad", description: "x" },
|
||||
"nope",
|
||||
]),
|
||||
).toEqual([{ name: "OK_VAR", description: "fine" }]);
|
||||
});
|
||||
|
||||
it("joins pages and anchors onto a docs link", () => {
|
||||
const base = "https://docs.termix.site/plugins/docker";
|
||||
expect(pluginDocsPage(base)).toBe(base);
|
||||
expect(pluginDocsPage(base, "/setup/")).toBe(`${base}/setup`);
|
||||
expect(pluginDocsPage(base, "setup", "#ports")).toBe(`${base}/setup#ports`);
|
||||
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* The onboarding plugin picker, against a real PluginLoader and fixture
|
||||
* plugins on disk. The database is faked.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
rows: new Map<string, Record<string, unknown>>(),
|
||||
settings: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
listAll: async () => [...db.rows.values()],
|
||||
findById: async (id: string) => db.rows.get(id) ?? null,
|
||||
update: async (id: string, input: Record<string, unknown>) => {
|
||||
const row = db.rows.get(id);
|
||||
if (!row) return null;
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) row[key] = value;
|
||||
}
|
||||
return row;
|
||||
},
|
||||
delete: async (id: string) => db.rows.delete(id),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => void db.settings.set(key, value),
|
||||
delete: async (key: string) => void db.settings.delete(key),
|
||||
}),
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
countKeys: async () => 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/data.js", () => ({
|
||||
migratePlugin: async () => [],
|
||||
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
|
||||
describePluginTables: async () => [],
|
||||
registerTable: () => ({}),
|
||||
forgetTables: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/index.js", async () => {
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const loader = new PluginLoader();
|
||||
return {
|
||||
getPluginRuntime: () => ({ loader }),
|
||||
activatePlugin: (id: string) => loader.activate(id),
|
||||
deactivatePlugin: (id: string) => loader.deactivate(id),
|
||||
registerLoadedPlugins: async (plugins: LoadedPlugin[]) => {
|
||||
for (const plugin of plugins) {
|
||||
db.rows.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
state: "enabled",
|
||||
lastError: null,
|
||||
pinnedVersion: null,
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
});
|
||||
}
|
||||
},
|
||||
unloadPlugin: async () => {},
|
||||
};
|
||||
});
|
||||
|
||||
const { getPluginRuntime, registerLoadedPlugins } =
|
||||
await import("../../plugins/index.js");
|
||||
const manage = await import("../../plugins/manage.js");
|
||||
const onboarding = await import("../../plugins/onboarding.js");
|
||||
const { resetBundledIndexCache } =
|
||||
await import("../../plugins/bundled-index.js");
|
||||
|
||||
const roots: string[] = [];
|
||||
let bundled: string;
|
||||
let data: string;
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
|
||||
async function chain() {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
createFixturePlugin({ id: "extra", root: bundled });
|
||||
const { loader } = getPluginRuntime();
|
||||
await manage.applyStoredPluginChoices(loader);
|
||||
const loaded = await loader.loadAll();
|
||||
await registerLoadedPlugins(loaded);
|
||||
for (const plugin of loaded) await loader.activate(plugin.id);
|
||||
return loader;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bundled = tempRoot("termix-bundled-");
|
||||
data = tempRoot("termix-data-");
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = data;
|
||||
db.rows.clear();
|
||||
db.settings.clear();
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { loader } = getPluginRuntime();
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("offerPluginSetup", () => {
|
||||
it("offers the picker once, as fresh or as an upgrade", async () => {
|
||||
await onboarding.offerPluginSetup(false);
|
||||
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(
|
||||
"upgrade",
|
||||
);
|
||||
expect(await onboarding.pluginSetupReason()).toBe("upgrade");
|
||||
|
||||
db.settings.delete(onboarding.PLUGIN_SETUP_PENDING_KEY);
|
||||
await onboarding.offerPluginSetup(true);
|
||||
expect(await onboarding.isPluginSetupPending()).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a fresh install", async () => {
|
||||
await onboarding.offerPluginSetup(true);
|
||||
expect(await onboarding.pluginSetupReason()).toBe("fresh");
|
||||
});
|
||||
|
||||
it("is not offered again after the picker was applied", async () => {
|
||||
await chain();
|
||||
await onboarding.offerPluginSetup(false);
|
||||
await onboarding.applyOnboardingChoices({});
|
||||
db.settings.delete(onboarding.PLUGIN_SETUP_OFFERED_KEY);
|
||||
await onboarding.applyOnboardingChoices({});
|
||||
await onboarding.offerPluginSetup(false);
|
||||
expect(await onboarding.isPluginSetupPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeOnboardingPlugins", () => {
|
||||
it("lists installed plugins with their defaults and the pending flag", async () => {
|
||||
writeIndex({
|
||||
base: { onboarding: { recommended: true } },
|
||||
extra: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
await chain();
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
|
||||
const list = await onboarding.describeOnboardingPlugins();
|
||||
expect(list.pending).toBe(true);
|
||||
expect(list.reason).toBe("fresh");
|
||||
expect(list.managedByLinkedServer).toBe(false);
|
||||
const byId = Object.fromEntries(list.plugins.map((p) => [p.id, p]));
|
||||
expect(byId.base).toMatchObject({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
source: "bundled",
|
||||
state: "enabled",
|
||||
});
|
||||
expect(byId.addon).toMatchObject({
|
||||
recommended: false,
|
||||
dependencies: ["base"],
|
||||
});
|
||||
expect(byId.extra).toMatchObject({ recommended: true, consent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOnboardingChoices", () => {
|
||||
it("enables, disables and removes, then clears the pending flag", async () => {
|
||||
const loader = await chain();
|
||||
db.rows.get("extra")!.state = "disabled";
|
||||
await loader.deactivate("extra");
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "disabled",
|
||||
addon: "remove",
|
||||
extra: "enabled",
|
||||
});
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.enabled).toEqual(["extra"]);
|
||||
expect(result.disabled).toEqual(["base"]);
|
||||
expect(result.removed).toEqual(["addon"]);
|
||||
expect(loader.get("addon")).toBeUndefined();
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
|
||||
"addon",
|
||||
]);
|
||||
expect(db.settings.has(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a dependency that a kept plugin still needs", async () => {
|
||||
const loader = await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "remove",
|
||||
addon: "disabled",
|
||||
});
|
||||
expect(result.adjustments).toEqual([
|
||||
{ id: "base", from: "remove", to: "disabled", requiredBy: ["addon"] },
|
||||
]);
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(loader.get("addon")?.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("removes dependents before what they depend on", async () => {
|
||||
const loader = await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "remove",
|
||||
addon: "remove",
|
||||
});
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.removed).toEqual(["addon", "base"]);
|
||||
expect(loader.get("base")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a dry run", async () => {
|
||||
const loader = await chain();
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
const result = await onboarding.applyOnboardingChoices(
|
||||
{ extra: "remove", base: "disabled", addon: "disabled" },
|
||||
{ dryRun: true },
|
||||
);
|
||||
expect(result.removed).toEqual(["extra"]);
|
||||
expect(result.disabled).toEqual(["addon", "base"]);
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1");
|
||||
});
|
||||
|
||||
it("ignores ids that are not installed", async () => {
|
||||
await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
ghost: "remove",
|
||||
});
|
||||
expect(result.resolved).toEqual({});
|
||||
expect(result.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for a plugin change already in progress", async () => {
|
||||
const loader = await chain();
|
||||
const first = manage.setPluginState("extra", false);
|
||||
const second = onboarding.applyOnboardingChoices({ extra: "enabled" });
|
||||
await first;
|
||||
const result = await second;
|
||||
expect(result.enabled).toEqual(["extra"]);
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getBundledPluginsDir } from "../../plugins/paths.js";
|
||||
|
||||
describe("getBundledPluginsDir", () => {
|
||||
const saved = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
else process.env.TERMIX_BUNDLED_PLUGINS_DIR = saved;
|
||||
});
|
||||
|
||||
it("reads dist/plugins when run from source", () => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
expect(getBundledPluginsDir()).toBe(
|
||||
path.resolve(process.cwd(), "dist", "plugins"),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors the override", () => {
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/somewhere/else";
|
||||
expect(getBundledPluginsDir()).toBe("/somewhere/else");
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
import type { PluginLoader } from "../../plugins/loader.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Fixture } from "./fixture-plugin.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -355,82 +353,6 @@ describe("the duplicate ai group is gone", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shipped manifests keep their permission ids", () => {
|
||||
const root = path.resolve(import.meta.dirname, "../../../../plugins");
|
||||
|
||||
function idsFor(pluginId: string): string[] {
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(path.join(root, pluginId, "manifest.json"), "utf8"),
|
||||
);
|
||||
const { manifest: parsed, errors } = parseManifest(raw);
|
||||
expect(errors).toEqual([]);
|
||||
return (parsed?.contributes?.permissions ?? []).map((entry) =>
|
||||
qualifyPermission(pluginId, entry.name),
|
||||
);
|
||||
}
|
||||
|
||||
// A role holding these today must keep working, which is only true because
|
||||
// <pluginId>.<name> reproduces the old id byte for byte.
|
||||
it("ai", () => {
|
||||
expect(idsFor("ai")).toEqual([
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
"ai.services.use",
|
||||
"ai.secrets.share",
|
||||
]);
|
||||
});
|
||||
|
||||
it("automations", () => {
|
||||
expect(idsFor("automations")).toEqual([
|
||||
"automations.view",
|
||||
"automations.create",
|
||||
"automations.edit",
|
||||
"automations.delete",
|
||||
"automations.run",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tailscale", () => {
|
||||
expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]);
|
||||
});
|
||||
|
||||
it("snippets", () => {
|
||||
expect(idsFor("snippets")).toEqual([
|
||||
"snippets.view",
|
||||
"snippets.create",
|
||||
"snippets.edit",
|
||||
"snippets.delete",
|
||||
"snippets.share",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a role holding the old ids still validates once registered", () => {
|
||||
for (const pluginId of ["ai", "automations", "tailscale", "snippets"]) {
|
||||
const ids = idsFor(pluginId);
|
||||
registerPluginPermissions({
|
||||
group: pluginId,
|
||||
pluginId,
|
||||
label: pluginId,
|
||||
permissions: ids,
|
||||
});
|
||||
}
|
||||
|
||||
const stored = [
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
"automations.run",
|
||||
"automations.view",
|
||||
"tailscale.devices.view",
|
||||
"snippets.view",
|
||||
"snippets.share",
|
||||
];
|
||||
|
||||
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role defaults apply exactly once", () => {
|
||||
let fixture: Fixture;
|
||||
let loader: PluginLoader;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
} from "@termix-ssh/plugin-sdk/db";
|
||||
import { createTableSql, dropTableSql } from "../../plugins/table-builder.js";
|
||||
import {
|
||||
adoptLegacyTableSql,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
|
||||
import type { DatabaseContext } from "../../database/repositories/database-context.js";
|
||||
|
||||
@@ -329,6 +329,23 @@ describe("a plugin protocol core does not know", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes the usable credential username without exposing its secret", async () => {
|
||||
const patch = readProtocolAuthPayload({
|
||||
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
|
||||
});
|
||||
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
|
||||
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
|
||||
expect(summaries.get(HOST_ID)?.spice.username).toBe("cred-user");
|
||||
expect(JSON.stringify(summaries.get(HOST_ID))).not.toContain("cred-pass");
|
||||
const recipient = sanitizeHostForRecipient(
|
||||
{ protocolAuth: summaries.get(HOST_ID) },
|
||||
"connect",
|
||||
);
|
||||
expect(recipient.protocolAuth).toEqual({
|
||||
spice: { authType: "credential" },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a shared editor change the login but not its credential", async () => {
|
||||
await storeLogin();
|
||||
const rename = readProtocolAuthPayload({
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/trust.js")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.value as never,
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
fetchRegistryStats,
|
||||
findRelease,
|
||||
latestOnChannel,
|
||||
getStatsUrl,
|
||||
isApiCompatible,
|
||||
parseRegistryIndex,
|
||||
parseRegistryStats,
|
||||
resetRegistryCache,
|
||||
type RegistryVersion,
|
||||
} from "../../plugins/registry-index.js";
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.value = {
|
||||
id: "test",
|
||||
publicKey: publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "26.10.0",
|
||||
};
|
||||
|
||||
function sign(buffer: Buffer) {
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
return {
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, privateKey).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function response(body: string | Buffer, status = 200, headers = {}) {
|
||||
return new Response(status >= 300 && status < 400 ? null : body, {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function version(overrides: Partial<RegistryVersion> = {}) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
api: "1",
|
||||
url: "https://example.com/p-1.0.0.tmxplug",
|
||||
sha256: "a".repeat(64),
|
||||
signature: "sig",
|
||||
size: 10,
|
||||
capabilities: ["kv:own"],
|
||||
publishedAt: "2026-10-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-registry-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fetchMock.mockReset();
|
||||
resetRegistryCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex", () => {
|
||||
it("keeps valid entries and sorts versions newest first", () => {
|
||||
const index = parseRegistryIndex({
|
||||
registry: "official",
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
versions: [version(), version({ version: "1.2.0" })],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(index.plugins).toHaveLength(1);
|
||||
expect(index.plugins[0].versions.map((v) => v.version)).toEqual([
|
||||
"1.2.0",
|
||||
"1.0.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops entries with a bad id, no https url or a malformed sha256", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{ id: "../evil", versions: [version()] },
|
||||
{ id: "plain", versions: [version({ url: "http://x/y.tmxplug" })] },
|
||||
{ id: "short", versions: [version({ sha256: "abc" })] },
|
||||
{ id: "ok", versions: [version()] },
|
||||
],
|
||||
});
|
||||
expect(index.plugins.map((p) => p.id)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("keeps release notes and only a YouTube video id", () => {
|
||||
const NOTES = "### Added\n- Thing";
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
video: "https://youtu.be/dQw4w9WgXcQ",
|
||||
versions: [version({ notes: NOTES })],
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
video: "https://evil.example/watch?v=dQw4w9WgXcQ",
|
||||
versions: [version({ notes: "x".repeat(30_000) })],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(index.plugins[0].videoId).toBe("dQw4w9WgXcQ");
|
||||
expect(index.plugins[0].versions[0].notes).toBe(NOTES);
|
||||
expect(index.plugins[1].videoId).toBeUndefined();
|
||||
expect(index.plugins[1].versions[0].notes).toHaveLength(20_000);
|
||||
});
|
||||
|
||||
it("keeps only usable feature lines", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{ id: "docker", features: ["Logs", 5, ""], versions: [version()] },
|
||||
{ id: "other", features: "Logs", versions: [version()] },
|
||||
],
|
||||
});
|
||||
expect(index.plugins[0].features).toEqual(["Logs"]);
|
||||
expect(index.plugins[1].features).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps only https docs links", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
docs: "https://docs.termix.site/plugins/docker/",
|
||||
versions: [version()],
|
||||
},
|
||||
{ id: "other", docs: "javascript:alert(1)", versions: [version()] },
|
||||
],
|
||||
});
|
||||
expect(index.plugins[0].docs).toBe(
|
||||
"https://docs.termix.site/plugins/docker",
|
||||
);
|
||||
expect(index.plugins[1].docs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses something that is not an index", () => {
|
||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRelease", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
versions: [
|
||||
version({ version: "2.0.0", api: "2" }),
|
||||
version({ version: "1.1.0" }),
|
||||
version(),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("picks the newest release this build can run", () => {
|
||||
expect(isApiCompatible("1")).toBe(true);
|
||||
expect(isApiCompatible("2")).toBe(false);
|
||||
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
|
||||
});
|
||||
|
||||
it("finds an exact version", () => {
|
||||
expect(findRelease(index, "docker", "1.0.0")?.release.version).toBe(
|
||||
"1.0.0",
|
||||
);
|
||||
expect(findRelease(index, "docker", "9.9.9")).toBeNull();
|
||||
expect(findRelease(index, "missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prereleases", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
versions: [
|
||||
version({ version: "1.1.0" }),
|
||||
version({ version: "1.2.0-beta.1" }),
|
||||
],
|
||||
prereleases: [
|
||||
version({ version: "1.2.0-beta.1" }),
|
||||
version({ version: "1.2.0-beta.10" }),
|
||||
version({ version: "1.3.0" }),
|
||||
],
|
||||
},
|
||||
{ id: "only-beta", prereleases: [version({ version: "0.1.0-beta.1" })] },
|
||||
],
|
||||
});
|
||||
const docker = index.plugins[0];
|
||||
|
||||
it("keeps betas out of the stable list and stable out of betas", () => {
|
||||
expect(docker.versions.map((v) => v.version)).toEqual(["1.1.0"]);
|
||||
expect(docker.prereleases.map((v) => v.version)).toEqual([
|
||||
"1.2.0-beta.10",
|
||||
"1.2.0-beta.1",
|
||||
]);
|
||||
expect(docker.prereleases.every((v) => v.prerelease)).toBe(true);
|
||||
expect(index.plugins[1].versions).toEqual([]);
|
||||
});
|
||||
|
||||
it("picks by channel and finds a beta by exact version", () => {
|
||||
expect(latestOnChannel(docker, "stable")?.version).toBe("1.1.0");
|
||||
expect(latestOnChannel(docker, "beta")?.version).toBe("1.2.0-beta.10");
|
||||
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
|
||||
expect(
|
||||
findRelease(index, "docker", undefined, "beta")?.release.version,
|
||||
).toBe("1.2.0-beta.10");
|
||||
expect(findRelease(index, "docker", "1.2.0-beta.1")?.release.version).toBe(
|
||||
"1.2.0-beta.1",
|
||||
);
|
||||
expect(findRelease(index, "only-beta")).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers a newer stable over an older beta", () => {
|
||||
const later = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
versions: [version({ version: "1.2.0" })],
|
||||
prereleases: [version({ version: "1.2.0-beta.3" })],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(latestOnChannel(later.plugins[0], "beta")?.version).toBe("1.2.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRegistryIndex", () => {
|
||||
const body = JSON.stringify({
|
||||
plugins: [{ id: "docker", versions: [version()] }],
|
||||
});
|
||||
|
||||
it("follows redirects and caches the result", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response("", 302, { location: "https://cdn.example.com/index.json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(response(body));
|
||||
|
||||
const first = await fetchRegistryIndex();
|
||||
const second = await fetchRegistryIndex();
|
||||
expect(first.plugins[0].id).toBe("docker");
|
||||
expect(second).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
"https://cdn.example.com/index.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the stale copy when a refresh fails", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response(body));
|
||||
const first = await fetchRegistryIndex();
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
expect(await fetchRegistryIndex({ force: true })).toBe(first);
|
||||
});
|
||||
|
||||
it("throws when there is nothing cached", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
await expect(fetchRegistryIndex()).rejects.toThrow(/503/);
|
||||
});
|
||||
});
|
||||
|
||||
/** A real .tmxplug holding only a manifest, enough for the checks. */
|
||||
function pack(manifest: Record<string, unknown>): Buffer {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest));
|
||||
const file = path.join(dir, "out.tmxplug");
|
||||
tar.c({ gzip: true, sync: true, file, cwd: dir }, ["manifest.json"]);
|
||||
return fs.readFileSync(file);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("downloadRelease", () => {
|
||||
const artifact = pack({
|
||||
id: "docker",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
});
|
||||
|
||||
it("writes a verified artifact and its signature to staging", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
|
||||
const result = await downloadRelease(
|
||||
"docker",
|
||||
version({ ...signed, size: artifact.length }),
|
||||
);
|
||||
expect(fs.readFileSync(result.file)).toEqual(artifact);
|
||||
expect(fs.readFileSync(result.signatureFile, "utf8")).toBe(
|
||||
signed.signature,
|
||||
);
|
||||
expect(
|
||||
result.file.startsWith(path.join(dataDir, "plugins", ".downloads")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a file signed by a key this build does not trust", async () => {
|
||||
const other = crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
const digest = crypto.createHash("sha256").update(artifact).digest();
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
await expect(
|
||||
downloadRelease(
|
||||
"docker",
|
||||
version({
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, other).toString("base64"),
|
||||
size: artifact.length,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/trusted key/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another plugin id", { id: "evil" }, /archive is for/],
|
||||
["another version", { version: "9.9.9" }, /archive is version/],
|
||||
[
|
||||
"more capabilities than the index lists",
|
||||
{ capabilities: ["kv:own", "credentials:read"] },
|
||||
/different capabilities/,
|
||||
],
|
||||
])("refuses a signed file with %s", async (_label, override, error) => {
|
||||
const tampered = pack({
|
||||
id: "docker",
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
...override,
|
||||
});
|
||||
const signed = sign(tampered);
|
||||
fetchMock.mockResolvedValueOnce(response(tampered));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: tampered.length })),
|
||||
).rejects.toThrow(error);
|
||||
expect(fs.existsSync(path.join(dataDir, "plugins", ".downloads"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a file that does not match its signature", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: 0 })),
|
||||
).rejects.toThrow(/sha256/);
|
||||
});
|
||||
|
||||
it("refuses a release built for another plugin API", async () => {
|
||||
await expect(
|
||||
downloadRelease("docker", version({ api: "2" })),
|
||||
).rejects.toThrow(/plugin API/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a download larger than the index says", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(Buffer.alloc(3 * 1024 * 1024)));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: 10 })),
|
||||
).rejects.toThrow(/larger than allowed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry stats", () => {
|
||||
it("sits next to the index", () => {
|
||||
expect(getStatsUrl()).toBe(
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/stats.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid counts and drops the rest", () => {
|
||||
const stats = parseRegistryStats({
|
||||
plugins: {
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
tunnels: { downloads: 7 },
|
||||
"../bad": { downloads: 1 },
|
||||
broken: { downloads: -3, activeInstalls: "many" },
|
||||
},
|
||||
});
|
||||
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
tunnels: { downloads: 7, activeInstalls: null },
|
||||
broken: { downloads: 0, activeInstalls: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches and parses stats.json", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||
);
|
||||
const stats = await fetchRegistryStats();
|
||||
expect(stats?.plugins.get("docker")).toEqual({
|
||||
downloads: 3,
|
||||
activeInstalls: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Puts a 2.8 install on a real Postgres or MySQL server for the upgrade test:
|
||||
* wipes the database, applies release-2.8.0-tag's own drizzle migrations
|
||||
* (UPGRADE_LEGACY_MIGRATIONS_DIR, which scripts/upgrade-check.sh extracts
|
||||
* from git), then copies every row of the SQLite fixture across. After that
|
||||
* the database is exactly what a 2.8 server left behind, and the normal boot
|
||||
* takes it the rest of the way.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import { ROWS } from "../fixtures/upgrade/rows.js";
|
||||
|
||||
export type RemoteDialect = "postgres" | "mysql";
|
||||
|
||||
type Remote = { execute: (query: SQL) => Promise<unknown> };
|
||||
|
||||
/** TEST_DIALECT, the same switch the repository tests use. */
|
||||
export function remoteTestDialect(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): RemoteDialect | null {
|
||||
const value = env.TEST_DIALECT?.trim().toLowerCase();
|
||||
return value === "postgres" || value === "mysql" ? value : null;
|
||||
}
|
||||
|
||||
async function rows<T>(db: Remote, query: SQL): Promise<T[]> {
|
||||
const result = (await db.execute(query)) as { rows?: T[] } | T[];
|
||||
if (Array.isArray(result)) {
|
||||
return (Array.isArray(result[0]) ? result[0] : result) as T[];
|
||||
}
|
||||
return result.rows ?? [];
|
||||
}
|
||||
|
||||
async function wipe(db: Remote, dialect: RemoteDialect): Promise<void> {
|
||||
if (dialect === "postgres") {
|
||||
await db.execute(sql.raw("DROP SCHEMA IF EXISTS drizzle CASCADE"));
|
||||
await db.execute(sql.raw("DROP SCHEMA public CASCADE"));
|
||||
await db.execute(sql.raw("CREATE SCHEMA public"));
|
||||
return;
|
||||
}
|
||||
const [{ name }] = await rows<{ name: string }>(
|
||||
db,
|
||||
sql.raw("SELECT DATABASE() AS name"),
|
||||
);
|
||||
await db.execute(sql.raw(`DROP DATABASE \`${name}\``));
|
||||
await db.execute(sql.raw(`CREATE DATABASE \`${name}\``));
|
||||
await db.execute(sql.raw(`USE \`${name}\``));
|
||||
}
|
||||
|
||||
async function columnTypes(
|
||||
db: Remote,
|
||||
dialect: RemoteDialect,
|
||||
table: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const schema =
|
||||
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
|
||||
const found = await rows<{ name: string; type: string }>(
|
||||
db,
|
||||
sql`SELECT column_name AS name, data_type AS type FROM information_schema.columns WHERE table_schema = ${schema} AND table_name = ${table}`,
|
||||
);
|
||||
return new Map(found.map((column) => [column.name, column.type]));
|
||||
}
|
||||
|
||||
async function resyncSequence(db: Remote, table: string): Promise<void> {
|
||||
const [sequence] = await rows<{ name: string | null }>(
|
||||
db,
|
||||
sql.raw(
|
||||
`SELECT pg_get_serial_sequence('"${table}"', 'id') AS name FROM information_schema.columns ` +
|
||||
`WHERE table_schema = current_schema() AND table_name = '${table}' AND column_name = 'id'`,
|
||||
),
|
||||
);
|
||||
if (!sequence?.name) return;
|
||||
await db.execute(
|
||||
sql.raw(
|
||||
`SELECT setval('${sequence.name}', COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function seedRemote28(
|
||||
dialect: RemoteDialect,
|
||||
url: string,
|
||||
fixture: Buffer,
|
||||
): Promise<void> {
|
||||
const legacyMigrations = process.env.UPGRADE_LEGACY_MIGRATIONS_DIR;
|
||||
if (!legacyMigrations) {
|
||||
throw new Error(
|
||||
"UPGRADE_LEGACY_MIGRATIONS_DIR must point at release-2.8.0-tag's drizzle folder. Run scripts/upgrade-check.sh.",
|
||||
);
|
||||
}
|
||||
|
||||
const { drizzle } =
|
||||
dialect === "postgres"
|
||||
? await import("drizzle-orm/node-postgres")
|
||||
: await import("drizzle-orm/mysql2");
|
||||
const db = (
|
||||
drizzle as unknown as (
|
||||
config: unknown,
|
||||
) => Remote & { $client: { end: () => Promise<void> } }
|
||||
)(
|
||||
dialect === "postgres"
|
||||
? { connection: url }
|
||||
: { connection: { uri: url, connectionLimit: 1 } },
|
||||
);
|
||||
const source = new Database(fixture, { readonly: true });
|
||||
|
||||
try {
|
||||
await wipe(db, dialect);
|
||||
|
||||
const { runRemoteMigrations } =
|
||||
await import("../../database/db/migrate.js");
|
||||
await runRemoteMigrations(dialect, db as never, {
|
||||
DRIZZLE_MIGRATIONS_DIR: legacyMigrations,
|
||||
});
|
||||
|
||||
for (const table of Object.keys(ROWS)) {
|
||||
const types = await columnTypes(db, dialect, table);
|
||||
if (types.size === 0) {
|
||||
throw new Error(`2.8's ${dialect} schema has no ${table} table`);
|
||||
}
|
||||
for (const row of source
|
||||
.prepare(`SELECT * FROM "${table}"`)
|
||||
.all() as Array<Record<string, unknown>>) {
|
||||
const columns = Object.keys(row).filter(
|
||||
(column) => types.has(column) && row[column] !== null,
|
||||
);
|
||||
const values = columns.map((column) =>
|
||||
types.get(column) === "boolean" ? Boolean(row[column]) : row[column],
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO ${sql.identifier(table)} (${sql.join(
|
||||
columns.map((column) => sql.identifier(column)),
|
||||
sql`, `,
|
||||
)}) VALUES (${sql.join(
|
||||
values.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
if (dialect === "postgres") await resyncSequence(db, table);
|
||||
}
|
||||
} finally {
|
||||
source.close();
|
||||
await db.$client.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildOpenApi,
|
||||
hasOpenApiPaths,
|
||||
TERMIX_SECURITY_SCHEMES,
|
||||
} from "@termix-ssh/plugin-sdk/openapi";
|
||||
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
function source(files: Record<string, string>): string {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-openapi-"));
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, name), text);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("buildOpenApi", () => {
|
||||
it("reads @openapi blocks from TypeScript sources", async () => {
|
||||
const root = source({
|
||||
"routes.ts": [
|
||||
"/**",
|
||||
" * @openapi",
|
||||
" * /plugin-api/sample/items:",
|
||||
" * get:",
|
||||
" * summary: List items",
|
||||
" * responses:",
|
||||
" * 200:",
|
||||
" * description: The items",
|
||||
" */",
|
||||
'router.get("/items", handler);',
|
||||
].join("\n"),
|
||||
});
|
||||
const spec = await buildOpenApi({
|
||||
title: "Sample API",
|
||||
version: "1.2.3",
|
||||
files: [path.join(root, "*.ts")],
|
||||
tags: [{ name: "Sample" }],
|
||||
});
|
||||
expect(hasOpenApiPaths(spec)).toBe(true);
|
||||
expect(Object.keys(spec.paths ?? {})).toEqual(["/plugin-api/sample/items"]);
|
||||
expect(spec).toMatchObject({
|
||||
openapi: "3.0.3",
|
||||
info: { title: "Sample API", version: "1.2.3" },
|
||||
tags: [{ name: "Sample" }],
|
||||
components: { securitySchemes: TERMIX_SECURITY_SCHEMES },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a spec with no paths when nothing is documented", async () => {
|
||||
const root = source({ "index.ts": "export const x = 1;" });
|
||||
const spec = await buildOpenApi({
|
||||
title: "Empty",
|
||||
version: "1.0.0",
|
||||
files: [path.join(root, "*.ts")],
|
||||
});
|
||||
expect(hasOpenApiPaths(spec)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { adoptTableSql } from "@termix/plugin-sdk/ddl";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
} from "@termix-ssh/plugin-sdk/db";
|
||||
import { adoptTableSql } from "@termix-ssh/plugin-sdk/ddl";
|
||||
import { createMockCtx, createTestDb } from "@termix-ssh/plugin-sdk/testing";
|
||||
|
||||
let root: string;
|
||||
let pluginDir: string;
|
||||
@@ -30,7 +30,7 @@ const notes = adoptLegacyTable(
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sdk-testing-"));
|
||||
// The directory name is the plugin id, as it is in plugins/.
|
||||
// The directory name is the plugin id.
|
||||
pluginDir = path.join(root, "fleets");
|
||||
fs.mkdirSync(path.join(pluginDir, "migrations", "sqlite"), {
|
||||
recursive: true,
|
||||
|
||||
@@ -107,7 +107,6 @@ function addUser(overrides: Record<string, unknown> = {}) {
|
||||
username: "alice",
|
||||
passwordHash: bcrypt.hashSync(PASSWORD, 4),
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
...overrides,
|
||||
};
|
||||
h.state.users.set(user.id as string, user as never);
|
||||
@@ -196,7 +195,6 @@ describe("external sign-in cannot claim another account", () => {
|
||||
email: null,
|
||||
name: "Alice",
|
||||
legacyIdentifier: "shared-id",
|
||||
ssoProviderId: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -209,7 +207,7 @@ describe("external sign-in cannot claim another account", () => {
|
||||
external: true,
|
||||
}),
|
||||
);
|
||||
h.state.settings.set("oidc_auto_provision", "true");
|
||||
h.state.settings.set("external_auto_provision", "true");
|
||||
});
|
||||
|
||||
async function signIn(identity: Record<string, unknown> = external) {
|
||||
@@ -223,55 +221,21 @@ describe("external sign-in cannot claim another account", () => {
|
||||
return h.state.identities.find((row) => row.subject === "sub-1")?.userId;
|
||||
}
|
||||
|
||||
it("never links a local password account by its old identifier", async () => {
|
||||
addUser({ id: "local", oidcIdentifier: "shared-id", isOidc: false });
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("local");
|
||||
});
|
||||
|
||||
it("never links an account that already has an identity", async () => {
|
||||
addUser({
|
||||
id: "taken",
|
||||
username: "bob",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
});
|
||||
it("never links an account that has no identity for this provider", async () => {
|
||||
addUser({ id: "local", username: "Alice" });
|
||||
addUser({ id: "sso", username: "bob", passwordHash: "" });
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "taken",
|
||||
userId: "sso",
|
||||
providerId: "9",
|
||||
subject: "bob",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("taken");
|
||||
});
|
||||
|
||||
it("never links an account from a different provider", async () => {
|
||||
addUser({
|
||||
id: "other",
|
||||
username: "bob",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
ssoProviderId: 7,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).not.toBe("other");
|
||||
});
|
||||
|
||||
it("still links a genuine 2.8 account on its first sign-in", async () => {
|
||||
addUser({
|
||||
id: "legacy",
|
||||
username: "Alice",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
oidcIdentifier: "shared-id",
|
||||
ssoProviderId: 3,
|
||||
});
|
||||
await signIn();
|
||||
expect(linkedTo()).toBe("legacy");
|
||||
expect(linkedTo()).not.toBe("local");
|
||||
expect(
|
||||
h.state.identities.find((row) => row.providerId === "3")?.userId,
|
||||
).not.toBe("sso");
|
||||
});
|
||||
|
||||
it("never gives a new account a local user's username", async () => {
|
||||
@@ -290,7 +254,6 @@ describe("external sign-in cannot claim another account", () => {
|
||||
id: "sso-user",
|
||||
username: "carol",
|
||||
passwordHash: "",
|
||||
isOidc: true,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
|
||||
@@ -37,7 +37,8 @@ function secretHost(id: number): Record<string, unknown> {
|
||||
}
|
||||
|
||||
vi.mock("../../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
const granted = (capability: string, declared: readonly string[]) =>
|
||||
declared.includes(capability) && h.granted.has(capability);
|
||||
return {
|
||||
|
||||
@@ -27,7 +27,8 @@ const { createPluginContext, createPluginHandle } =
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../../plugins/permissions.js");
|
||||
const { runAsActor } = await import("../../../plugins/actor.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const PLUGIN_ID = "walker";
|
||||
const ACTOR = "user-walker";
|
||||
|
||||
@@ -9,13 +9,11 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectOwnedIndexes,
|
||||
findUnownedTableWrites,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
} from "@termix-ssh/plugin-sdk/ddl";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
@@ -155,39 +153,6 @@ describe("the migration checker", () => {
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("passes every migration a bundled plugin ships", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../plugins",
|
||||
);
|
||||
for (const id of fs.readdirSync(plugins)) {
|
||||
const legacy = new Set(
|
||||
Object.entries(LEGACY_TABLE_OWNERS)
|
||||
.filter(([, owner]) => owner === id)
|
||||
.map(([table]) => table),
|
||||
);
|
||||
for (const dialect of ["sqlite", "postgres", "mysql"]) {
|
||||
const dir = path.join(plugins, id, "migrations", dialect);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort();
|
||||
const sqls = files.map((file) =>
|
||||
fs.readFileSync(path.join(dir, file), "utf8"),
|
||||
);
|
||||
// The runner lets a later migration drop an index an earlier one made.
|
||||
const ownedIndexes = collectOwnedIndexes(id, sqls, legacy);
|
||||
files.forEach((file, index) => {
|
||||
expect(
|
||||
findUnownedTableWrites(id, sqls[index], legacy, ownedIndexes),
|
||||
`${id}/${dialect}/${file}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a user plugin no legacy exemption at runtime", () => {
|
||||
const adopt = 'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"';
|
||||
expect(() =>
|
||||
|
||||
@@ -9,10 +9,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PluginLoader, prefixesOverlap } from "../../../plugins/loader.js";
|
||||
import { parseManifest } from "../../../plugins/manifest.js";
|
||||
import { RESERVED_PLUGIN_IDS } from "@termix/plugin-sdk/manifest";
|
||||
import { RESERVED_PLUGIN_IDS } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { createFixturePlugin } from "../fixture-plugin.js";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
@@ -95,22 +94,6 @@ describe("table prefixes", () => {
|
||||
loader.load(path.join(root, "foo-bar"), "user"),
|
||||
).rejects.toThrow(/overlaps "foo"/);
|
||||
});
|
||||
|
||||
it("no two bundled plugins overlap", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../plugins",
|
||||
);
|
||||
const ids = fs
|
||||
.readdirSync(plugins, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
for (const a of ids) {
|
||||
for (const b of ids) {
|
||||
expect(prefixesOverlap(a, b), `${a} and ${b}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths stay inside the plugin", () => {
|
||||
|
||||
@@ -398,7 +398,7 @@ describe("host field options", () => {
|
||||
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
|
||||
});
|
||||
|
||||
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
|
||||
it("accepts enableDefault, shareRead and ownerOnly", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host(
|
||||
@@ -407,7 +407,6 @@ describe("host field options", () => {
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultFrom: "defaultOn",
|
||||
shareRead: "edit",
|
||||
ownerOnly: true,
|
||||
},
|
||||
@@ -418,7 +417,7 @@ describe("host field options", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("still accepts an old defaultFrom, but only as a string", () => {
|
||||
it("rejects the removed defaultFrom", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
@@ -429,11 +428,6 @@ describe("host field options", () => {
|
||||
defaultFrom: "missing",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
|
||||
).join(),
|
||||
).toMatch(/defaultFrom/);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
|
||||
@@ -34,7 +34,19 @@ vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: vi.fn(),
|
||||
}));
|
||||
|
||||
const CONSOLE_PORT = 39109;
|
||||
// A fixed port can land in a range Windows reserves for Hyper-V or Docker.
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const CONSOLE_PORT = await freePort();
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
prefixedTableName,
|
||||
tablePrefix,
|
||||
adoptLegacyTable,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
} from "@termix-ssh/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix-ssh/plugin-sdk/ddl";
|
||||
import {
|
||||
adoptTableSql,
|
||||
createTableSql,
|
||||
@@ -184,9 +184,9 @@ describe("createTableSql", () => {
|
||||
\`user_id\` varchar(255) NOT NULL,
|
||||
\`host_id\` int NOT NULL,
|
||||
\`title\` varchar(200) NOT NULL,
|
||||
\`body\` text,
|
||||
\`meta\` text,
|
||||
\`secret\` text,
|
||||
\`body\` longtext,
|
||||
\`meta\` longtext,
|
||||
\`secret\` longtext,
|
||||
\`pinned\` boolean DEFAULT false,
|
||||
\`position\` int DEFAULT 0,
|
||||
\`size\` bigint,
|
||||
@@ -286,9 +286,9 @@ describe("adoptTableSql", () => {
|
||||
expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;");
|
||||
});
|
||||
|
||||
it("writes a TEXT default as an expression on MySQL", () => {
|
||||
it("writes a long text default as an expression on MySQL", () => {
|
||||
expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain(
|
||||
"`name` text NOT NULL DEFAULT ('x')",
|
||||
"`name` longtext NOT NULL DEFAULT ('x')",
|
||||
);
|
||||
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
|
||||
"\"name\" text NOT NULL DEFAULT 'x'",
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* The committed 2.8 fixture still matches rows.ts and still fills every
|
||||
* table a plugin adopts. A new legacy table in LEGACY_TABLE_OWNERS fails here
|
||||
* until it gets fixture rows (npx tsx scripts/build-upgrade-fixture.ts).
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { UPGRADE_FIXTURE_DIR } from "./built-harness.js";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
RECORDING_FILES,
|
||||
ROWS,
|
||||
} from "../fixtures/upgrade/rows.js";
|
||||
|
||||
const sqlite = new Database(path.join(UPGRADE_FIXTURE_DIR, "db.sqlite"), {
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
afterAll(() => sqlite.close());
|
||||
|
||||
const count = (table: string) =>
|
||||
(
|
||||
sqlite.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
|
||||
describe("the committed 2.8 upgrade fixture", () => {
|
||||
it("fills every table a plugin adopts", () => {
|
||||
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
|
||||
expect(ROWS[table]?.length ?? 0, table).toBeGreaterThan(0);
|
||||
expect(count(table), table).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds exactly the rows in rows.ts", () => {
|
||||
for (const [table, rows] of Object.entries(ROWS)) {
|
||||
// Each user also has a wrapped data key in settings.
|
||||
const extra = table === "settings" ? ROWS.users.length : 0;
|
||||
expect(count(table), table).toBe(rows.length + extra);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores the columns 2.8 encrypted as ciphertext", () => {
|
||||
for (const [table, column] of ENCRYPTED_COLUMNS) {
|
||||
const values = (
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT "${column}" AS value FROM "${table}" WHERE "${column}" IS NOT NULL AND "${column}" <> ''`,
|
||||
)
|
||||
.all() as Array<{ value: string }>
|
||||
).map((row) => row.value);
|
||||
for (const value of values) {
|
||||
expect(FieldCrypto.isEncrypted(value), `${table}.${column}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("ships the recording files its rows point at", () => {
|
||||
for (const relative of Object.values(RECORDING_FILES)) {
|
||||
expect(
|
||||
fs.existsSync(path.join(UPGRADE_FIXTURE_DIR, "files", relative)),
|
||||
relative,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,741 +0,0 @@
|
||||
/**
|
||||
* The 2.8 to 2.9.0 upgrade, end to end. The committed 2.8 install in
|
||||
* fixtures/upgrade boots through the pre-upgrade backup, core's migrations,
|
||||
* every built plugin's migrations and the data moves, and everything it held
|
||||
* reads back through the plugins' routes, their settings and their ctx. Then
|
||||
* the same data directory boots again and nothing changes.
|
||||
*
|
||||
* TEST_DIALECT=postgres|mysql with TEST_DATABASE_URL runs it against a real
|
||||
* server instead (scripts/upgrade-check.sh sets that up).
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import {
|
||||
assertPluginsBuilt,
|
||||
bootCore,
|
||||
BUILT_PLUGINS_DIR,
|
||||
UPGRADE_FIXTURE_DIR,
|
||||
type BootedCore,
|
||||
} from "./built-harness.js";
|
||||
import { remoteTestDialect } from "./remote-seed.js";
|
||||
import {
|
||||
FIXTURE_PASSWORD,
|
||||
HOST_AUTH_TYPES,
|
||||
HOSTS,
|
||||
LDAP_PROVIDER_ID,
|
||||
OPERATORS_PERMISSIONS,
|
||||
ROWS,
|
||||
SSO_PROVIDER_ID,
|
||||
SYNCED_ENTITY_TYPES_28,
|
||||
TOTP_BACKUP_CODES,
|
||||
TOTP_SECRET,
|
||||
USER_ROLE_PERMISSIONS_28,
|
||||
USERS,
|
||||
} from "../fixtures/upgrade/rows.js";
|
||||
|
||||
const BOOT_TIMEOUT = 240_000;
|
||||
|
||||
type UserKey = keyof typeof USERS;
|
||||
|
||||
interface Booted {
|
||||
core: BootedCore;
|
||||
app: express.Express;
|
||||
tokens: Record<UserKey, string>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** legacy table -> the p_ table its plugin renamed it to. */
|
||||
function adoptedNames(): Map<string, string> {
|
||||
const names = new Map<string, string>();
|
||||
for (const id of new Set(Object.values(LEGACY_TABLE_OWNERS))) {
|
||||
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
const text = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
for (const match of text.matchAll(
|
||||
/ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)"/g,
|
||||
)) {
|
||||
names.set(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
async function boot(dataDir?: string): Promise<Booted> {
|
||||
vi.resetModules();
|
||||
const errors: string[] = [];
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
let core: BootedCore;
|
||||
try {
|
||||
core = await bootCore(
|
||||
dataDir ? { reuseDataDir: dataDir } : { fixtureDir: UPGRADE_FIXTURE_DIR },
|
||||
);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const auth = AuthManager.getInstance();
|
||||
const tokens = {} as Record<UserKey, string>;
|
||||
for (const [key, userId] of Object.entries(USERS)) {
|
||||
await auth.unlockWithSystemKey(userId);
|
||||
tokens[key as UserKey] = await auth.generateJWTToken(userId, {
|
||||
deviceType: "web",
|
||||
deviceInfo: "upgrade test",
|
||||
});
|
||||
}
|
||||
|
||||
const { mountPluginApi } =
|
||||
await import("../../database/routes/plugin-api-routes.js");
|
||||
const { default: pluginRoutes } =
|
||||
await import("../../database/routes/plugins.js");
|
||||
const { default: userRoutes } =
|
||||
await import("../../database/routes/users.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
mountPluginApi(app);
|
||||
|
||||
return { core, app, tokens, errors };
|
||||
}
|
||||
|
||||
function get(booted: Booted, url: string, as: UserKey = "admin") {
|
||||
return request(booted.app)
|
||||
.get(url)
|
||||
.set("Authorization", `Bearer ${booted.tokens[as]}`);
|
||||
}
|
||||
|
||||
async function settings(
|
||||
booted: Booted,
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
as: UserKey = "admin",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await get(
|
||||
booted,
|
||||
`/plugins/${pluginId}/settings/${scope}`,
|
||||
as,
|
||||
);
|
||||
expect(response.status, `${pluginId} ${scope} settings`).toBe(200);
|
||||
return response.body.values as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A ctx for a running plugin, the one its own code gets. */
|
||||
async function pluginCtx(pluginId: string) {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const { createPluginContext } = await import("../../plugins/ctx.js");
|
||||
const loaded = getPluginRuntime().loader.get(pluginId);
|
||||
if (!loaded?.handle) throw new Error(`${pluginId} is not running`);
|
||||
return createPluginContext(loaded.manifest, loaded.handle);
|
||||
}
|
||||
|
||||
async function asUser<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const { runAsActor } = await import("../../plugins/actor.js");
|
||||
return runAsActor(userId, "asUser", fn);
|
||||
}
|
||||
|
||||
/** Every row of a plugin table, read through that plugin's ctx.db. */
|
||||
async function pluginRows(
|
||||
pluginId: string,
|
||||
table: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const ctx = await pluginCtx(pluginId);
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const client = await ctx.db.client<any>();
|
||||
const query = sql`SELECT * FROM ${sql.identifier(table)}`;
|
||||
if (typeof client.all === "function") return client.all(query);
|
||||
const result = await client.execute(query);
|
||||
return Array.isArray(result) ? result[0] : result.rows;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
/** Bookkeeping counts, compared between the first and second boot. */
|
||||
async function counts(): Promise<Record<string, number>> {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const out: Record<string, number> = {};
|
||||
const tables = [
|
||||
"plugin_migrations",
|
||||
"plugin_settings",
|
||||
"user_second_factors",
|
||||
"user_external_identities",
|
||||
"roles",
|
||||
"user_roles",
|
||||
"ssh_data",
|
||||
...adoptedNames().values(),
|
||||
];
|
||||
for (const table of tables) {
|
||||
const [row] = await selectRows<{ n: number | string }>(
|
||||
sql`SELECT COUNT(*) AS n FROM ${sql.identifier(table)}`,
|
||||
);
|
||||
out[table] = Number(row.n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function totpCode(secret: string, at = Date.now()): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let bits = "";
|
||||
for (const char of secret.replace(/=+$/, "")) {
|
||||
bits += alphabet.indexOf(char).toString(2).padStart(5, "0");
|
||||
}
|
||||
const key = Buffer.from(
|
||||
bits.match(/.{8}/g)!.map((byte) => parseInt(byte, 2)),
|
||||
);
|
||||
const counter = Buffer.alloc(8);
|
||||
counter.writeBigUInt64BE(BigInt(Math.floor(at / 30_000)));
|
||||
const hmac = crypto.createHmac("sha1", key).update(counter).digest();
|
||||
const offset = hmac[hmac.length - 1] & 0xf;
|
||||
const value = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
||||
return String(value).padStart(6, "0");
|
||||
}
|
||||
|
||||
/** Everything a 2.8 install had, checked against a booted 2.9.0. */
|
||||
function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
it("boots without logging an error", () => {
|
||||
expect(current().errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("activates every plugin", async () => {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const notActive = getPluginRuntime()
|
||||
.loader.list()
|
||||
.filter((plugin) => plugin.state !== "active")
|
||||
.map((plugin) => `${plugin.id}: ${plugin.state} ${plugin.lastError}`);
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every row of every adopted table, readable by its plugin", async () => {
|
||||
const names = adoptedNames();
|
||||
for (const [legacy, pluginId] of Object.entries(LEGACY_TABLE_OWNERS)) {
|
||||
const seeded = ROWS[legacy] ?? [];
|
||||
expect(seeded.length, `the fixture fills ${legacy}`).toBeGreaterThan(0);
|
||||
const table = names.get(legacy);
|
||||
expect(table, `${pluginId} adopts ${legacy}`).toBeDefined();
|
||||
// 2.8 kept LDAP directories in sso_providers; the ldap plugin takes them.
|
||||
const expected =
|
||||
legacy === "sso_providers"
|
||||
? seeded.filter((row) => row.type !== "ldap")
|
||||
: seeded;
|
||||
const rows = await pluginRows(pluginId, table!);
|
||||
const ids = rows.map((row) => String(row.id)).sort();
|
||||
expect(ids, `${legacy} -> ${table}`).toEqual(
|
||||
expected.map((row) => String(row.id)).sort(),
|
||||
);
|
||||
}
|
||||
const ldap = await pluginRows("ldap", "p_ldap_providers");
|
||||
expect(ldap.map((row) => String(row.id))).toEqual([
|
||||
String(LDAP_PROVIDER_ID),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the adopted rows back through each plugin's routes", async () => {
|
||||
const checks: Array<[string, string, UserKey?]> = [
|
||||
["/plugin-api/snippets/", "d4-snippet"],
|
||||
["/plugin-api/snippets/shared", "d4-shared-snippet", "alice"],
|
||||
["/plugin-api/fleets/", "d4-fleet"],
|
||||
["/plugin-api/workspaces/", "d4-workspace"],
|
||||
["/plugin-api/network-topology/", "d4-node"],
|
||||
["/plugin-api/automations/", "d4-automation"],
|
||||
["/plugin-api/homepage/items", "d4-widget"],
|
||||
["/plugin-api/homepage/service-links", "d4-link"],
|
||||
["/plugin-api/vault/profiles", "d4-vault"],
|
||||
["/plugin-api/termix-identity/me", "d4-handle"],
|
||||
["/plugin-api/sso/providers", "d4-sso"],
|
||||
["/plugin-api/ldap/providers", "d4-ldap"],
|
||||
[
|
||||
`/plugin-api/ssh-terminal/command-history/${HOSTS.password}`,
|
||||
"d4-history-command",
|
||||
],
|
||||
["/plugin-api/tunnels/presets", "d4-preset"],
|
||||
["/plugin-api/ai/providers", "d4-provider"],
|
||||
["/plugin-api/ai/conversations/1", "d4-message"],
|
||||
["/plugin-api/secret-sources", "d4-source"],
|
||||
["/plugin-api/session-sharing/rooms", "d4-room"],
|
||||
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
|
||||
["/plugin-api/alerts/channels", "d4-channel"],
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const [url, marker, as] of checks) {
|
||||
const response = await get(current(), url, as);
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
!JSON.stringify(response.body).includes(marker)
|
||||
) {
|
||||
failures.push(
|
||||
`${url} -> ${response.status} ${JSON.stringify(response.body).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves every host flag into its plugin's host settings", async () => {
|
||||
const booted = current();
|
||||
const host = (id: string) => settings(booted, id, `host/${HOSTS.password}`);
|
||||
expect(await host("ssh-terminal")).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
// The look it saved in terminal_config, which took it off the user's.
|
||||
inheritAppearance: false,
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
cursorStyle: "block",
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
localEcho: "on",
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "ssh-terminal", `host/${HOSTS.key}`),
|
||||
).toMatchObject({ inheritAppearance: true, passwordPromptAutoFill: false });
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: false,
|
||||
});
|
||||
expect(await host("session-sharing")).toMatchObject({
|
||||
allowSessionSharing: false,
|
||||
});
|
||||
expect(await host("file-manager")).toMatchObject({
|
||||
enableFileManager: false,
|
||||
defaultPath: "/srv/d4",
|
||||
scpLegacy: true,
|
||||
});
|
||||
const tunnels = await host("tunnels");
|
||||
expect(tunnels.enableTunnel).toBe(true);
|
||||
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
|
||||
const web = await host("web-endpoint");
|
||||
expect(web.enableWebUi).toBe(true);
|
||||
expect(JSON.stringify(web.webUiConfig)).toContain("d4-grafana");
|
||||
expect(await host("tmux-monitor")).toMatchObject({
|
||||
enableTmuxMonitor: true,
|
||||
});
|
||||
expect(await host("docker")).toMatchObject({
|
||||
enableDocker: true,
|
||||
containerRuntime: "podman",
|
||||
});
|
||||
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
|
||||
expect(await host("snippets")).toMatchObject({
|
||||
startupSnippetId: 1,
|
||||
quickActions: [{ name: "d4-uptime", snippetId: 1 }],
|
||||
});
|
||||
expect(await host("wake-on-lan")).toMatchObject({
|
||||
macAddress: "AA:BB:CC:DD:EE:04",
|
||||
broadcastAddress: "10.4.0.255",
|
||||
});
|
||||
expect(await host("host-metrics")).toMatchObject({
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
enabledWidgets: ["cpu", "memory"],
|
||||
});
|
||||
|
||||
const proxmox = await settings(booted, "proxmox", `host/${HOSTS.key}`);
|
||||
expect(proxmox.enableProxmox).toBe(true);
|
||||
expect(JSON.stringify(proxmox)).toContain("pve.d4.example");
|
||||
|
||||
expect(
|
||||
await settings(booted, "remote-desktop", `host/${HOSTS.desktop}`),
|
||||
).toMatchObject({
|
||||
enableRdp: true,
|
||||
enableVnc: true,
|
||||
enableTelnet: true,
|
||||
rdpPort: 3390,
|
||||
vncPort: 5901,
|
||||
telnetPort: 2323,
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "remote-desktop", `host/${HOSTS.rdpOnly}`),
|
||||
).toMatchObject({ enableRdp: true });
|
||||
|
||||
expect(
|
||||
await settings(booted, "vault", `host/${HOSTS.vault}`),
|
||||
).toMatchObject({ profileId: 1 });
|
||||
expect(
|
||||
await settings(booted, "warpgate", `host/${HOSTS.warpgate}`),
|
||||
).toMatchObject({ useWarpgate: true });
|
||||
});
|
||||
|
||||
it("keeps 2.8's defaults on a host that never changed them", async () => {
|
||||
const booted = current();
|
||||
const host = (id: string) => settings(booted, id, `host/${HOSTS.plain}`);
|
||||
expect(await host("file-manager")).toMatchObject({
|
||||
enableFileManager: true,
|
||||
});
|
||||
expect(await host("ssh-terminal")).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableTerminalToolbar: true,
|
||||
inheritAppearance: true,
|
||||
autoTmux: false,
|
||||
passwordPromptAutoFill: true,
|
||||
});
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: true,
|
||||
});
|
||||
expect(await host("session-sharing")).toMatchObject({
|
||||
allowSessionSharing: true,
|
||||
});
|
||||
expect(await host("docker")).toMatchObject({ enableDocker: false });
|
||||
expect(await host("tmux-monitor")).toMatchObject({
|
||||
enableTmuxMonitor: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps each host's connection and secrets", async () => {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const hosts = await selectRows<{
|
||||
id: number;
|
||||
auth_type: string;
|
||||
enable_ssh: unknown;
|
||||
}>(sql`SELECT id, auth_type, enable_ssh FROM ssh_data ORDER BY id`);
|
||||
const byId = new Map(hosts.map((row) => [row.id, row]));
|
||||
for (const [id, authType] of Object.entries(HOST_AUTH_TYPES)) {
|
||||
// Warpgate became a plugin setting on a host with no SSH auth of its own.
|
||||
const want = authType === "warpgate" ? "none" : authType;
|
||||
expect(byId.get(Number(id))?.auth_type, `host ${id}`).toBe(want);
|
||||
}
|
||||
expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0);
|
||||
|
||||
// The connection options in terminal_config moved to core's ssh_options.
|
||||
const [options] = await selectRows<{ ssh_options: string | null }>(
|
||||
sql`SELECT ssh_options FROM ssh_data WHERE id = ${HOSTS.password}`,
|
||||
);
|
||||
expect(JSON.parse(options.ssh_options ?? "{}")).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 4,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
});
|
||||
|
||||
// Every 2.8 RDP, VNC and Telnet login reads back through
|
||||
// host_protocol_auth, the only place core reads them from now.
|
||||
const ctx = await pluginCtx("remote-desktop");
|
||||
const resolve = (hostId: number, protocol: string) =>
|
||||
asUser(USERS.admin, () =>
|
||||
ctx.credentials.resolveHostProtocol(hostId, protocol),
|
||||
);
|
||||
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
fields: { domain: "D4-DOMAIN" },
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-vnc-user",
|
||||
password: "d4-vnc-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "deploy",
|
||||
});
|
||||
|
||||
const logins = await selectRows<{ host_id: number; protocol: string }>(
|
||||
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
|
||||
);
|
||||
expect(
|
||||
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
|
||||
).toEqual([
|
||||
`${HOSTS.rdpOnly}:rdp`,
|
||||
`${HOSTS.desktop}:rdp`,
|
||||
`${HOSTS.desktop}:telnet`,
|
||||
`${HOSTS.desktop}:vnc`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers an SSH auth provider for every 2.8 auth type", async () => {
|
||||
const { listSshAuthProviders } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const types = new Set(listSshAuthProviders().map((p) => p.type));
|
||||
for (const authType of new Set(Object.values(HOST_AUTH_TYPES))) {
|
||||
if (authType === "warpgate") continue;
|
||||
expect(types, authType).toContain(authType);
|
||||
}
|
||||
});
|
||||
|
||||
it("plays back every 2.8 recording", async () => {
|
||||
const booted = current();
|
||||
const list = await get(booted, "/plugin-api/session-recording/");
|
||||
expect(
|
||||
(list.body.logs as Array<{ id: number }>).map((log) => log.id).sort(),
|
||||
).toEqual([1, 2]);
|
||||
for (const [id, kind] of [
|
||||
[1, "ssh"],
|
||||
[2, "rdp"],
|
||||
] as const) {
|
||||
const content = await get(
|
||||
booted,
|
||||
`/plugin-api/session-recording/${id}/content`,
|
||||
)
|
||||
.buffer(true)
|
||||
.parse((res, done) => {
|
||||
let text = "";
|
||||
res.on("data", (chunk: Buffer) => (text += chunk.toString()));
|
||||
res.on("end", () => done(null, text));
|
||||
});
|
||||
expect(content.status, `recording ${id}`).toBe(200);
|
||||
expect(content.body).toContain(`d4 ${kind} recording`);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts every moved setting in its plugin's admin and user scope", async () => {
|
||||
const booted = current();
|
||||
expect(await settings(booted, "ssh-terminal", "admin")).toMatchObject({
|
||||
sessionTimeoutMinutes: 45,
|
||||
sessionPersistence: false,
|
||||
commandHistoryForNewHosts: false,
|
||||
imageMaxCount: 7,
|
||||
newHostFontSize: 20,
|
||||
newHostTheme: "nord",
|
||||
newHostAutoTmux: true,
|
||||
});
|
||||
expect(await settings(booted, "ssh-terminal", "user")).toMatchObject({
|
||||
terminalDefaults: { fontSize: 18, cursorBlink: false },
|
||||
customThemes: [expect.objectContaining({ id: "d4-theme" })],
|
||||
commandAutocomplete: true,
|
||||
macros: [expect.objectContaining({ id: "d4-macro", name: "d4 macro" })],
|
||||
});
|
||||
expect(await settings(booted, "snippets", "user")).toMatchObject({
|
||||
confirmExecution: true,
|
||||
foldersCollapsed: false,
|
||||
});
|
||||
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
|
||||
caUrl: "https://ca.d4.example",
|
||||
fingerprint: "d4fingerprint",
|
||||
provisioner: "d4-provisioner",
|
||||
});
|
||||
expect(await settings(booted, "remote-desktop", "admin")).toMatchObject({
|
||||
enabled: true,
|
||||
guacdUrl: "guacd.d4.example:4822",
|
||||
});
|
||||
expect(await settings(booted, "host-metrics", "admin")).toMatchObject({
|
||||
metricsInterval: 40,
|
||||
historyRetentionDays: 14,
|
||||
enabledForNewHosts: false,
|
||||
});
|
||||
expect(await settings(booted, "ai", "admin")).toMatchObject({
|
||||
globallyEnabled: true,
|
||||
});
|
||||
expect(await settings(booted, "session-recording", "admin")).toMatchObject({
|
||||
retentionDays: 3650,
|
||||
});
|
||||
expect(
|
||||
JSON.stringify((await settings(booted, "ai", "admin")).privateEndpoints),
|
||||
).toContain("10.4.0.0/24");
|
||||
expect(await settings(booted, "session-sharing", "admin")).toMatchObject({
|
||||
globallyEnabled: false,
|
||||
});
|
||||
expect(await settings(booted, "telemetry", "admin")).toMatchObject({
|
||||
enabled: false,
|
||||
instanceId: "d4-instance-id",
|
||||
});
|
||||
expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({
|
||||
domain: "termix.d4.example",
|
||||
email: "d4@example.com",
|
||||
});
|
||||
expect(await settings(booted, "tailscale", "admin")).toMatchObject({
|
||||
apiBaseUrl: "https://headscale.d4.example",
|
||||
});
|
||||
expect(await settings(booted, "ai", "user")).toMatchObject({
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: true,
|
||||
});
|
||||
expect(await settings(booted, "remote-desktop", "user")).toMatchObject({
|
||||
colorDepth: "16",
|
||||
resizeMethod: "reconnect",
|
||||
enableDrive: "on",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every secret readable by the plugin that owns it", async () => {
|
||||
const tailscale = await pluginCtx("tailscale");
|
||||
expect(await tailscale.settings.get("apiKey")).toBe("tskey-d4");
|
||||
const acme = await pluginCtx("acme-ssl");
|
||||
expect(JSON.stringify(await acme.settings.getAll("admin"))).toContain(
|
||||
"d4-cloudflare-token",
|
||||
);
|
||||
const ai = await pluginCtx("ai");
|
||||
expect(await asUser(USERS.admin, () => ai.secrets.get("provider:1"))).toBe(
|
||||
"sk-d4-provider-key",
|
||||
);
|
||||
const sources = await pluginCtx("secret-sources");
|
||||
expect(
|
||||
await asUser(USERS.admin, () => sources.secrets.get("source:d4-source")),
|
||||
).toBe("d4-source-token");
|
||||
});
|
||||
|
||||
it("moves every notification channel into alerts, config readable", async () => {
|
||||
const response = await get(current(), "/plugin-api/alerts/channels/1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
name: "d4-channel",
|
||||
type: "ntfy",
|
||||
usable: true,
|
||||
config: { url: "https://ntfy.d4.example/d4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every role's permissions", async () => {
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const permissions = PermissionManager.getInstance();
|
||||
for (const permission of OPERATORS_PERMISSIONS) {
|
||||
expect(
|
||||
await permissions.hasPermission(USERS.alice, permission),
|
||||
`operators: ${permission}`,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const permission of USER_ROLE_PERMISSIONS_28) {
|
||||
expect(
|
||||
await permissions.hasPermission(USERS.passkey, permission),
|
||||
`user: ${permission}`,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(await permissions.hasPermission(USERS.admin, "sso.manage")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("still asks a 2.8 TOTP user for a code, and accepts one", async () => {
|
||||
const booted = current();
|
||||
const login = await request(booted.app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect(login.body.requires_totp).toBe(true);
|
||||
expect(JSON.stringify(login.body.second_factors)).toContain("totp");
|
||||
|
||||
const wrong = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({ temp_token: login.body.temp_token, totp_code: "000000" });
|
||||
expect(wrong.status).not.toBe(200);
|
||||
|
||||
const verify = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({
|
||||
temp_token: login.body.temp_token,
|
||||
totp_code: totpCode(TOTP_SECRET),
|
||||
});
|
||||
expect(verify.status).toBe(200);
|
||||
|
||||
const again = await request(booted.app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
|
||||
const backup = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({
|
||||
temp_token: again.body.temp_token,
|
||||
totp_code: TOTP_BACKUP_CODES[bootIndex],
|
||||
});
|
||||
expect(backup.status).toBe(200);
|
||||
});
|
||||
|
||||
it("signs a password user without a second factor straight in", async () => {
|
||||
const login = await request(current().app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-alice", password: FIXTURE_PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect(login.body.requires_totp).toBeFalsy();
|
||||
});
|
||||
|
||||
it("links SSO and LDAP users to their providers", async () => {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const links = await selectRows<{
|
||||
user_id: string;
|
||||
provider_id: unknown;
|
||||
}>(sql`SELECT user_id, provider_id FROM user_external_identities`);
|
||||
const byUser = new Map(
|
||||
links.map((row) => [row.user_id, String(row.provider_id)]),
|
||||
);
|
||||
expect(byUser.get(USERS.sso)).toBe(String(SSO_PROVIDER_ID));
|
||||
// The ldap plugin signs in with "ldap:<provider>".
|
||||
expect(byUser.get(USERS.ldap)).toBe(`ldap:${LDAP_PROVIDER_ID}`);
|
||||
});
|
||||
|
||||
it("syncs every entity type 2.8 synced, under the same name", async () => {
|
||||
const { listEntityTypes } = await import("../../plugins/sync-registry.js");
|
||||
const { registerCoreSyncEntities } = await import("../../sync/entities.js");
|
||||
registerCoreSyncEntities();
|
||||
const known = new Set(listEntityTypes());
|
||||
for (const type of SYNCED_ENTITY_TYPES_28) {
|
||||
expect(known, type).toContain(type);
|
||||
}
|
||||
|
||||
const snippets = await pluginRows("snippets", "p_snippets_snippets");
|
||||
expect(snippets.map((row) => row.sync_id)).toContain("d4-sync-snippet");
|
||||
const vault = await pluginRows("vault", "p_vault_profiles");
|
||||
expect(vault.map((row) => row.sync_id)).toContain("d4-sync-vault");
|
||||
});
|
||||
}
|
||||
|
||||
let first: Booted;
|
||||
let firstCounts: Record<string, number>;
|
||||
let second: Booted;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertPluginsBuilt();
|
||||
first = await boot();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await (second ?? first)?.core.shutdown();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
describe("a 2.8 install upgraded to 2.9.0", () => {
|
||||
// Only SQLite is backed up by Termix; a server database is the operator's.
|
||||
it.skipIf(remoteTestDialect())(
|
||||
"backed up the 2.8 database before anything touched it",
|
||||
async () => {
|
||||
const backups = path.join(first.core.dataDir, "backups");
|
||||
const dirs = fs
|
||||
.readdirSync(backups)
|
||||
.filter((name) => name.startsWith("pre-plugin-runtime-"));
|
||||
expect(dirs).toHaveLength(1);
|
||||
const copy = fs.readFileSync(path.join(backups, dirs[0], "db.sqlite"));
|
||||
expect(copy.equals(first.core.seed!)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
upgradeChecks(() => first, 0);
|
||||
});
|
||||
|
||||
describe("booting the upgraded install again", () => {
|
||||
beforeAll(async () => {
|
||||
firstCounts = await counts();
|
||||
const dataDir = first.core.dataDir;
|
||||
await first.core.shutdown({ keepDataDir: true });
|
||||
second = await boot(dataDir);
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
it("changes nothing", async () => {
|
||||
expect(await counts()).toEqual(firstCounts);
|
||||
});
|
||||
|
||||
it.skipIf(remoteTestDialect())("takes no second backup", () => {
|
||||
const backups = fs.readdirSync(path.join(second.core.dataDir, "backups"));
|
||||
expect(
|
||||
backups.filter((name) => name.startsWith("pre-plugin-runtime-")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
upgradeChecks(() => second, 1);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
refUser,
|
||||
text,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
} from "@termix-ssh/plugin-sdk/db";
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
@@ -117,6 +117,44 @@ describe("plugin user data", () => {
|
||||
expect(again).toMatchObject({ imported: 0, skipped: 1 });
|
||||
});
|
||||
|
||||
it("exports and imports a table keyed by its user column", async () => {
|
||||
const keyed = defineTable("keyed", {
|
||||
userId: refUser().primaryKey(),
|
||||
secret: encryptedText(),
|
||||
label: text(),
|
||||
});
|
||||
for (const statement of createTableSql("sqlite", "fixture", keyed)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
registerTable("fixture", keyed);
|
||||
sqlite.exec(
|
||||
"INSERT INTO p_fixture_keyed (user_id, secret, label) VALUES ('u1', 'sealed', 'mine')",
|
||||
);
|
||||
|
||||
const file = new Database(":memory:");
|
||||
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
file.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
await writeUserPluginTables(file, "u1");
|
||||
expect(file.prepare("SELECT * FROM p_fixture_keyed").all()).toEqual([
|
||||
{ user_id: "u1", secret: null, label: "mine" },
|
||||
]);
|
||||
|
||||
const result = await importUserPluginRows(file, "u2");
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, label FROM p_fixture_keyed WHERE user_id = 'u2'",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ user_id: "u2", label: "mine" }]);
|
||||
// The user already has their one row, so a second import skips it.
|
||||
expect(await importUserPluginRows(file, "u2")).toMatchObject({
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("writes large tables in chunks and lets the event loop run between them", async () => {
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { WebSocket } from "ws";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
validToken: "valid-token",
|
||||
ticket: "socket-ticket",
|
||||
userId: "user-1",
|
||||
granted: new Set<string>(["network:serve"]),
|
||||
}));
|
||||
@@ -31,8 +32,14 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
verifyJWTToken: async (token: string) =>
|
||||
token === state.validToken ? { userId: state.userId } : null,
|
||||
verifyJWTToken: async (
|
||||
token: string,
|
||||
options: { allowSocketTicket?: boolean } = {},
|
||||
) =>
|
||||
token === state.validToken ||
|
||||
(token === state.ticket && options.allowSocketTicket)
|
||||
? { userId: state.userId }
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -91,6 +98,21 @@ function connect(
|
||||
});
|
||||
}
|
||||
|
||||
function connectWithHeaders(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
protocols: string[] = [],
|
||||
): Promise<{ outcome: "open"; socket: WebSocket } | { outcome: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new WebSocket(url, protocols, { headers });
|
||||
socket.once("open", () => resolve({ outcome: "open", socket }));
|
||||
socket.once("unexpected-response", (_req, res) =>
|
||||
resolve({ outcome: res.statusCode ?? 0 }),
|
||||
);
|
||||
socket.once("error", () => resolve({ outcome: 0 }));
|
||||
});
|
||||
}
|
||||
|
||||
function authProtocols(token: string): string[] {
|
||||
return [`termix.jwt.${token}`];
|
||||
}
|
||||
@@ -159,6 +181,78 @@ describe("ctx.ws routing", () => {
|
||||
});
|
||||
|
||||
describe("ctx.ws authentication", () => {
|
||||
it("ignores the jwt cookie on an upgrade from another site", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connectWithHeaders(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
{ Cookie: `jwt=${state.validToken}`, Origin: "https://evil.example" },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts the jwt cookie from this server's own page", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connectWithHeaders(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
{
|
||||
Cookie: `jwt=${state.validToken}`,
|
||||
Origin: base.replace(/^ws:/, "http:"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("open");
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("hides cross-site cookies from a public socket's handler", async () => {
|
||||
const cookies: Array<string | undefined> = [];
|
||||
const users: string[] = [];
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/public",
|
||||
({ request, userId, socket }) => {
|
||||
cookies.push(request.headers.cookie);
|
||||
users.push(userId);
|
||||
(socket as WebSocket).close();
|
||||
},
|
||||
DECLARED,
|
||||
{ public: true, optionalAuth: true },
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connectWithHeaders(
|
||||
`${base}/plugin-ws/sample-plugin/public`,
|
||||
{ Cookie: `jwt=${state.validToken}`, Origin: "https://evil.example" },
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("open");
|
||||
await vi.waitFor(() => expect(users).toHaveLength(1));
|
||||
expect(users).toEqual([""]);
|
||||
expect(cookies).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("accepts a socket ticket when the proxy hid the page's origin", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connectWithHeaders(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
{
|
||||
Cookie: `jwt=${state.validToken}`,
|
||||
Origin: "https://termix.example.com",
|
||||
},
|
||||
authProtocols(state.ticket),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("open");
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("rejects an upgrade with no token", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
|
||||
Reference in new issue
Block a user