Files
Termix/packages/plugin-sdk/src/testing.ts
T
Luke GustafsonGitHubZacharyZcRdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0149260926 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>
2026-10-10 14:58:27 -05:00

2242 lines
72 KiB
TypeScript

/**
* Test helpers for plugin authors.
*
* createMockCtx is the one to reach for: it enforces capabilities the way the
* real runtime does. createFakeContext stays for tests that only need a
* context-shaped object and do not care about the gates.
*
* The vitest config these run under comes from @termix-ssh/plugin-sdk/vitest-preset.
*/
import {
LoginMethodError,
PLUGIN_HOST_INPUT_KEYS,
PluginCapabilityError,
} from "./backend.js";
import type {
PluginContext,
PluginDisposables,
PluginLogger,
} from "./backend.js";
import type { HostActionContribution, TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type {
PluginMiddleware,
PluginRouterOptions,
PluginWebSocketHandler,
PluginWebSockets,
PluginWebSocketOptions,
} from "./backend.js";
/** The bits of an express Response the fake completeRedirectLogin uses. */
interface FakeResponse {
status: (code: number) => FakeResponse;
json: (body: unknown) => unknown;
redirect: (url: string) => unknown;
}
/** A ctx.ws registration as the test doubles record it. */
export interface FakeWsRoute {
path: string;
raw: boolean;
/** For ctx.ws.route. */
handler?: PluginWebSocketHandler;
/** For ctx.ws.upgrade. */
upgrade?: Parameters<PluginWebSockets["upgrade"]>[1];
options?: PluginWebSocketOptions;
}
import type {
PluginLoginMethod,
PluginLoginRequest,
PluginVerifiedIdentity,
PluginSecondFactor,
PluginSshAuthProvider,
PluginSshConnectOptions,
PluginSshHost,
PluginHostSummary,
PluginHostRecord,
PluginHostCreateInput,
PluginHostUpdateInput,
PluginHostAccess,
PluginHostShareResult,
PluginShareableUser,
PluginShareableRole,
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginExternalClientRequest,
PluginProtocolTarget,
PluginHostStatusEntry,
PluginNotification,
PluginNotificationChannel,
PluginNotifyHub,
PluginFetchInit,
PluginBinarySpec,
PluginTlsCertificateInfo,
PluginTlsStatus,
PluginSummary,
PluginKeyboardInteractiveHandler,
PluginProcessHandle,
PluginProcessOptions,
} from "./backend.js";
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
export interface FakeScheduledJob {
kind: "every" | "after";
ms: number;
fn: () => void | Promise<void>;
stopped: boolean;
}
export interface FakeContextOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
/** Seed for admin-scope ctx.settings. */
settings?: Record<string, unknown>;
/** Seed for the core settings ctx.settings.readCore can reach. */
coreSettings?: Record<string, string>;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
/** A real database behind ctx.db, usually createTestDb().database. */
db?: PluginDatabase;
/** What ctx.http.router returns, e.g. () => express.Router(). */
router?: () => unknown;
/**
* Role permissions the acting user holds, as full ids or this plugin's
* short names. When set, ctx.rbac enforces them and require() answers 403;
* when omitted every check passes, so tests that do not care stay simple.
*/
permissions?: string[];
/**
* Services other plugins provide, for testing a consumer. Keyed by service
* name, or "<service>#<provider>" for a named provider.
*/
services?: Record<string, object>;
/** Hosts ctx.hosts.list/get/checkAccess answer with. */
hosts?: PluginHostSummary[];
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */
shareableUsers?: PluginShareableUser[];
shareableRoles?: PluginShareableRole[];
/** Hosts ctx.ssh.resolveHost answers with, secrets included. */
sshHosts?: PluginSshHost[];
/**
* What ctx.credentials.resolveHostProtocol answers, keyed
* "<hostId>:<protocol>". A missing key answers null.
*/
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys answers. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
/** What ctx.hosts.status.get and check answer, by host id. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
* Answers ctx.fetch. Without it every fetch rejects, so a test never
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/**
* Answers ctx.process. Without run every ctx.process.run rejects; without
* ensureBinary it answers "/tmp/<pluginId>/bin/<name>".
*/
process?: FakeProcessOptions;
/**
* What ctx.system.tlsStatus answers before any write. renewal is filled in
* from registerTlsRenewer calls.
*/
tlsStatus?: Omit<PluginTlsStatus, "renewal">;
/** What ctx.plugins.list answers. Default: only this plugin, active. */
installedPlugins?: PluginSummary[];
/**
* Checks a ctx.system.writeTlsCertificate call. Throw to reject the pair.
* Without it every pair is accepted and described with placeholder info.
*/
validateTls?: (
certificatePem: string,
privateKeyPem: string,
) => PluginTlsCertificateInfo;
/**
* Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted
* proxy login on, password login off), with this message and a 409.
*/
refuseEnrollment?: string;
/** What ctx.http.baseUrl answers. Defaults to "https://termix.test". */
baseUrl?: string;
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
}
export interface FakeProcessOptions {
run?: (
file: string,
args: readonly string[],
options?: PluginProcessOptions,
) => PluginProcessHandle | Promise<PluginProcessHandle>;
ensureBinary?: (spec: PluginBinarySpec) => Promise<string>;
}
/** A ctx.process handle a test drives by hand. */
export interface FakeProcess extends PluginProcessHandle {
emitStdout: (chunk: string) => void;
emitStderr: (chunk: string) => void;
exit: (code: number | null, signal?: string | null) => void;
killed: string[];
}
export function createFakeProcess(pid = 4242): FakeProcess {
const stdout: Array<(chunk: string) => void> = [];
const stderr: Array<(chunk: string) => void> = [];
const killed: string[] = [];
let resolveExit: (value: {
code: number | null;
signal: string | null;
}) => void = () => {};
const exited = new Promise<{ code: number | null; signal: string | null }>(
(resolve) => {
resolveExit = resolve;
},
);
return {
pid,
onStdout: (listener) => void stdout.push(listener),
onStderr: (listener) => void stderr.push(listener),
exited,
kill: (signal = "SIGTERM") => {
killed.push(signal);
resolveExit({ code: null, signal });
},
emitStdout: (chunk) => stdout.forEach((listener) => listener(chunk)),
emitStderr: (chunk) => stderr.forEach((listener) => listener(chunk)),
exit: (code, signal = null) => resolveExit({ code, signal }),
killed,
};
}
export interface FakeAuthRegistrations {
sshAuthProviders: PluginSshAuthProvider[];
keyboardInteractiveHandlers: PluginKeyboardInteractiveHandler[];
loginMethods: PluginLoginMethod[];
secondFactors: PluginSecondFactor[];
/** "<userId>:<factorId>" for every recorded enrolment. */
enrollments: Set<string>;
/** Secret schemes registered through ctx.credentials.registerSecretResolver. */
secretResolvers: Map<
string,
(userId: string, reference: string) => Promise<string>
>;
/** Identities handed to ctx.auth.completeRedirectLogin, in order. */
completedLogins: Array<{
methodId: string;
identity: PluginVerifiedIdentity;
}>;
/** Every ctx.auth.revokeSessions call, in order. */
revokedSessions: Array<{
providerId?: number | null;
sub?: string | null;
sid?: string | null;
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>;
}
export interface FakePluginContext {
ctx: PluginContext;
/** Runs what ctx.settings.onValidate registered, as a settings save would. */
validateSettings: (
scope: "admin" | "user" | "host",
values: Record<string, unknown>,
context?: { hostId?: number },
) => Promise<Record<string, string>>;
/** Everything the plugin registered through ctx.disposables. */
disposals: Array<() => void | Promise<void>>;
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
kv: Map<string, unknown>;
/** Table definitions registered through ctx.db.define, in order. */
tables: PluginTableDefinition[];
/** Sync entities registered through ctx.sync.registerEntity, in order. */
syncEntities: SyncEntityRegistration[];
/**
* WebSocket routes registered through ctx.ws, in order, with the handler
* and options, so a test can hand a route a fake socket.
*/
wsRoutes: FakeWsRoute[];
/** Router options passed to ctx.http.router, in order. */
httpRouters: Array<PluginRouterOptions | undefined>;
/** Backing store behind ctx.settings, keyed "<scope>:<scopeId>:<key>". */
settings: Map<string, unknown>;
/** Core settings readable through ctx.settings.readCore. */
coreSettings: Map<string, string>;
/** Every ctx.ssh.connect and withConnection call, in order. */
sshConnections: Array<{
host: number | PluginSshHost;
pool?: string;
options?: PluginSshConnectOptions;
}>;
/** Every ctx.hosts.share call, in order. */
hostShares: Array<{
hostId: number;
targets: unknown[];
permissionLevel: string;
durationHours?: number;
}>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
/** Every ctx.desktop.openIsolatedWindow call, in order. */
desktopWindows: Array<{
url: string;
partition?: string;
title?: string;
ignoreCert?: boolean;
}>;
/** Every ctx.desktop.launchExternalClient call, in order. */
externalClientLaunches: PluginExternalClientRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.credentials.createSshKey call, with the id it answered. */
createdSshKeys: Array<PluginSshKeyCredentialInput & { id: number }>;
/** Every ctx.notify.send call, with the actor it ran as. */
notifications: Array<{
actor: string | undefined;
notification: PluginNotification;
}>;
/** The hub the plugin registered with ctx.notify.serve, if any. */
notifyHub: { current: PluginNotifyHub | null };
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */
fetches: Array<{ url: string; init?: PluginFetchInit }>;
/** Every ctx.process.run call, in order. */
processRuns: Array<{
file: string;
args: readonly string[];
options?: PluginProcessOptions;
}>;
/** Every ctx.process.ensureBinary call, in order. */
binaries: PluginBinarySpec[];
/** What ctx.system did: accepted writes, reloads, live challenges and renewers. */
tls: {
writes: Array<{ certificatePem: string; privateKeyPem: string }>;
reloads: number;
/** ctx.system calls by name, in order. */
calls: string[];
challenges: Map<string, string>;
renewers: number;
};
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
activities: Array<{ hostId: number; type: string; hostName: string }>;
/** Host ids with a live ctx.hosts.trackSession, one entry per session. */
trackedSessions: number[];
/** Every ctx.ssh.startInteraction / cancelInteraction call, in order. */
interactions: Array<{
action: "start" | "cancel";
interaction: string;
request: Record<string, unknown>;
}>;
/** Every ctx.hosts.status.reportLogin call, in order. */
statusReports: Array<{
hostId: number;
ok: boolean;
hostKeyChanged?: boolean;
}>;
/** ctx.hosts.status.registerPort resolvers, by connection type. */
statusPorts: Map<string, (hostId: number) => unknown>;
/** Backing store behind ctx.hosts.status.get and check. */
hostStatuses: Map<number, PluginHostStatusEntry>;
/** Every ctx.ssh.dropPooled call, in order. */
droppedPools: Array<{ pool: string; hostId: number }>;
/** Every ctx.schedule timer, stopped ones included. */
scheduled: FakeScheduledJob[];
/** Runs every live ctx.schedule job once; "after" jobs are then done. */
runScheduled: () => Promise<void>;
/** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void;
/**
* Runs `fn` as `userId`, the way a request from that user would. For test
* code standing in for a caller; unlike ctx.asUser it needs no capability.
*/
actAs: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** Provided or seeded services, by name or "<service>#<provider>". */
services: Map<string, object>;
}
const CORE_PERMISSION_GROUPS = new Set([
"hosts",
"snippets",
"credentials",
"admin",
]);
/**
* A short name takes the plugin's prefix. An id already under this plugin or
* a core group is used as given, like src/backend/plugins/rbac.ts does.
*/
function qualifyPermission(pluginId: string, permission: string): string {
const head = permission.split(".")[0];
if (head === pluginId || CORE_PERMISSION_GROUPS.has(head)) return permission;
return `${pluginId}.${permission}`;
}
/** How a settings row is keyed in the doubles. Mirrors the real unique index. */
function settingsKey(
scope: string,
scopeId: string | number | undefined,
key: string,
): string {
return `${scope}:${scopeId ?? ""}:${key}`;
}
function noopLogger(): PluginLogger {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
}
const HOST_INPUT_KEYS = new Set<string>(PLUGIN_HOST_INPUT_KEYS);
/** Refuses a key ctx.hosts does not take, the way core does. */
function checkHostInput(input: PluginHostUpdateInput): void {
const unknown = Object.keys(input ?? {}).filter(
(key) => !HOST_INPUT_KEYS.has(key),
);
if (unknown.length > 0) {
throw new Error(`ctx.hosts does not take ${unknown.join(", ")}`);
}
}
function emptyHostRecord(id: number, userId: string): PluginHostRecord {
return {
id,
userId,
syncId: null,
name: null,
ip: "",
port: 22,
username: "",
authType: "password",
credentialId: null,
overrideCredentialUsername: false,
connectionType: "ssh",
tags: null,
folder: null,
parentHostId: null,
pin: false,
notes: null,
jumpHosts: [],
enableSsh: true,
sshPort: null,
statusCheckEnabled: true,
statusCheckInterval: null,
connectionOrigin: null,
sshOptions: {},
pluginSettings: {},
status: null,
localOnly: false,
createdAt: null,
updatedAt: null,
};
}
function applyHostInput(
record: PluginHostRecord,
input: PluginHostUpdateInput,
): PluginHostRecord {
const {
tags,
pluginSettings: _settings,
forceKeyboardInteractive: _fki,
...rest
} = input;
const next: PluginHostRecord = { ...record };
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined)
(next as unknown as Record<string, unknown>)[key] = value;
}
if (tags !== undefined) {
next.tags = Array.isArray(tags) ? tags.join(",") : tags;
}
if (next.jumpHosts === null) next.jumpHosts = [];
if (next.sshOptions === null) next.sshOptions = {};
return next;
}
/**
* A context double good enough to call activate() against in a unit test.
* Nothing here is capability-checked: a test asserting a capability gate
* should use the real runtime, not this.
*/
export function createFakeContext(
options: FakeContextOptions = {},
): FakePluginContext {
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void | Promise<void>> = [];
const emitted: Array<{ topic: string; payload: unknown }> = [];
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
const wsRoutes: FakeWsRoute[] = [];
const httpRouters: Array<PluginRouterOptions | undefined> = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>();
const settings = new Map<string, unknown>();
const coreSettings = new Map<string, string>(
Object.entries(options.coreSettings ?? {}),
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
const settingsValidators = new Set<{
scope: "admin" | "user" | "host";
validator: Parameters<PluginContext["settings"]["onValidate"]>[1];
}>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = [];
const tls: FakePluginContext["tls"] = {
writes: [],
reloads: 0,
calls: [],
challenges: new Map(),
renewers: 0,
};
let tlsState: Omit<PluginTlsStatus, "renewal"> = options.tlsStatus ?? {
enabled: true,
certificate: null,
};
const secretStore = new Map<string, string>();
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
const interactions: FakePluginContext["interactions"] = [];
const statusReports: FakePluginContext["statusReports"] = [];
const statusPorts: FakePluginContext["statusPorts"] = new Map();
const hostStatuses = new Map<number, PluginHostStatusEntry>(
Object.entries(options.hostStatuses ?? {}).map(([id, entry]) => [
Number(id),
entry,
]),
);
const droppedPools: FakePluginContext["droppedPools"] = [];
const scheduled: FakeScheduledJob[] = [];
const schedule = (
kind: FakeScheduledJob["kind"],
ms: number,
fn: FakeScheduledJob["fn"],
) => {
const job: FakeScheduledJob = { kind, ms, fn, stopped: false };
scheduled.push(job);
const stop = () => {
job.stopped = true;
};
disposals.push(stop);
return stop;
};
const hostsById = new Map<number, PluginHostSummary>(
(options.hosts ?? []).map((h) => [h.id, h]),
);
const hostRecordsById = new Map<number, PluginHostRecord>();
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
const services = new Map<string, object>(
Object.entries(options.services ?? {}),
);
const serviceKey = (service: string, name?: string) =>
name ? `${service}#${name}` : service;
const registryProviders = new Map<string, unknown>();
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
keyboardInteractiveHandlers: [],
loginMethods: [],
secondFactors: [],
enrollments: new Set(),
secretResolvers: new Map(),
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
const held = options.permissions
? new Set(
options.permissions.map((permission) =>
qualifyPermission(pluginId, permission),
),
)
: null;
const holds = (permission: string) =>
held === null || held.has(qualifyPermission(pluginId, permission));
// Like the runtime, a route can only require a permission the manifest
// declares. Checked when the test hands in a manifest.
const declaredPermissions = options.manifest?.contributes?.permissions
? new Set(
options.manifest.contributes.permissions.map((entry) =>
qualifyPermission(pluginId, entry.name),
),
)
: null;
for (const [key, value] of Object.entries(options.settings ?? {})) {
settings.set(settingsKey("admin", undefined, key), value);
}
const writeSetting = (
scope: string,
scopeId: string | number | undefined,
key: string,
value: unknown,
) => {
settings.set(settingsKey(scope, scopeId, key), value);
for (const listener of settingsListeners.get(key) ?? []) listener(value);
};
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...options.manifest,
} as PluginManifest;
const disposables: PluginDisposables = {
add: (dispose) => disposals.push(dispose),
};
const ctx: PluginContext = {
pluginId,
manifest,
log: noopLogger(),
events: {
emit: (topic, payload) => {
emitted.push({ topic, payload });
for (const listener of listeners.get(topic) ?? []) listener(payload);
},
on: (topic, listener) => {
let set = listeners.get(topic);
if (!set) {
set = new Set();
listeners.set(topic, set);
}
set.add(listener);
return () => set!.delete(listener);
},
},
kv: {
get: async (key) => (kv.has(key) ? kv.get(key) : null),
set: async (key, value) => {
kv.set(key, value);
},
delete: async (key) => kv.delete(key),
list: async () => [...kv.keys()],
},
files: {
// A fake path; a test that needs a real folder uses the OS temp dir
// itself, not this double.
dataDir: async () => `/tmp/${pluginId}`,
},
db: options.db
? {
...options.db,
define: async (definition) => {
tables.push(definition);
return options.db!.define(definition);
},
client: () => options.db!.client(),
refs: () => options.db!.refs(),
persist: () => options.db!.persist(),
dialect: options.db.dialect,
}
: {
// No engine behind this one. Pass createTestDb().database as `db`
// for a test that needs real SQL.
define: async (definition) => {
tables.push(definition);
return undefined as never;
},
client: async () => {
throw new Error(
"createFakeContext has no database: pass createTestDb().database as db",
);
},
refs: async () => {
throw new Error(
"createFakeContext has no database: pass createTestDb().database as db",
);
},
persist: async () => {},
dialect: "sqlite",
},
sync: {
registerEntity: (entity) => {
syncEntities.push(entity);
},
},
registry: {
provide: (key, value) => {
assertOwnRegistryKey(pluginId, key);
registryProviders.set(key, value);
},
consume: (key) => registryProviders.get(key) as never,
revoke: (key, value) => {
assertOwnRegistryKey(pluginId, key);
if (!registryProviders.has(key)) return false;
if (value !== undefined && registryProviders.get(key) !== value) {
return false;
}
return registryProviders.delete(key);
},
},
services: {
provide: (service, implementation, provideOptions) => {
services.set(serviceKey(service, provideOptions?.name), implementation);
},
// What this context provided or the test seeded through `services`.
// Like the real handle, a missing provider is an empty object, which a
// consumer's `"method" in handle` probe reads as absent.
get: (service, getOptions) =>
(services.get(serviceKey(service, getOptions?.provider)) ??
{}) as never,
providers: (service) =>
[...services.keys()].flatMap((key) =>
key === service
? [""]
: key.startsWith(`${service}#`)
? [key.slice(service.length + 1)]
: [],
),
},
secrets: {
get: async (key) => secretStore.get(`${actor ?? ""}:${key}`) ?? null,
set: async (key, value) => {
if (value === null) secretStore.delete(`${actor ?? ""}:${key}`);
else secretStore.set(`${actor ?? ""}:${key}`, value);
},
delete: async (key) => {
secretStore.delete(`${actor ?? ""}:${key}`);
},
seal: async (value) =>
`sealed:${Buffer.from(value, "utf8").toString("base64")}`,
unseal: async (sealed) =>
sealed?.startsWith("sealed:")
? Buffer.from(sealed.slice(7), "base64").toString("utf8")
: null,
offer: () => {},
withdraw: () => false,
getShared: async () => null,
},
http: {
// No express here: a test that needs a real router should mount the
// plugin's own route module against its own express app.
router: (routerOptions) => {
httpRouters.push(routerOptions);
return (options.router ? options.router() : undefined) as never;
},
baseUrl: () => options.baseUrl ?? "https://termix.test",
},
ws: {
route: (path, handler, wsOptions) => {
wsRoutes.push({ path, raw: false, handler, options: wsOptions });
},
upgrade: (path, handler, wsOptions) => {
wsRoutes.push({
path,
raw: true,
upgrade: handler,
options: wsOptions,
});
},
},
rbac: {
// Enforces options.permissions for the acting user, or passes
// everything when none were given. The runtime's own resolution against
// other plugins' namespaces is covered by core's tests, not here.
has: async (permission) => holds(permission),
hasFor: async (_userId, permission) => holds(permission),
require: (permission) => {
if (
declaredPermissions &&
!declaredPermissions.has(qualifyPermission(pluginId, permission))
) {
throw new Error(
`Plugin ${pluginId} cannot require permission "${permission}": it is not declared in contributes.permissions`,
);
}
return ((_req, res, next) => {
if (holds(permission)) {
next();
return;
}
res.status(403).json({
error: "Insufficient permissions",
required: qualifyPermission(pluginId, permission),
});
}) as PluginMiddleware;
},
},
// Nothing here is capability-checked, per the module doc; createMockCtx
// gates this against options.capabilities.
capabilities: {
has: async () => true,
require: async () => {},
},
settings: {
get: async (key) =>
settings.get(settingsKey("admin", undefined, key)) as never,
set: async (key, value) => writeSetting("admin", undefined, key, value),
getUser: async (userId, key) =>
settings.get(settingsKey("user", userId, key)) as never,
setUser: async (userId, key, value) =>
writeSetting("user", userId, key, value),
getHost: async (hostId, key) =>
settings.get(settingsKey("host", String(hostId), key)) as never,
getHostFor: async (hostId, _userId, key) =>
settings.get(settingsKey("host", String(hostId), key)) as never,
// Host defaults live in core; the mock stands in the user's own
// setting of the same key, so a test can set one with setUser.
getHostDefault: async (userId, key) =>
settings.get(settingsKey("user", userId, key)) as never,
setHost: async (hostId, key, value, options) =>
options?.inherit
? void settings.delete(settingsKey("host", String(hostId), key))
: writeSetting("host", String(hostId), key, value),
listHostValues: async (key) => {
const found: Array<{ hostId: number; userId: string; value: never }> =
[];
for (const [storedKey, value] of settings) {
const [scope, scopeId, name] = storedKey.split(":");
if (scope !== "host" || name !== key || value === undefined) continue;
const hostId = Number(scopeId);
found.push({
hostId,
userId: hostRecordsById.get(hostId)?.userId ?? actor ?? "",
value: value as never,
});
}
return found;
},
getAll: async (scope, scopeId) => {
const prefix = `${scope}:${scopeId ?? ""}:`;
const all: Record<string, unknown> = {};
for (const [storedKey, value] of settings) {
if (storedKey.startsWith(prefix)) {
all[storedKey.slice(prefix.length)] = value;
}
}
return all;
},
onChange: (key, listener) => {
let set = settingsListeners.get(key);
if (!set) {
set = new Set();
settingsListeners.set(key, set);
}
set.add(listener);
const unsubscribe = () => {
set!.delete(listener);
};
disposals.push(unsubscribe);
return unsubscribe;
},
onValidate: (scope, validator) => {
const entry = { scope, validator };
settingsValidators.add(entry);
const unsubscribe = () => {
settingsValidators.delete(entry);
};
disposals.push(unsubscribe);
return unsubscribe;
},
readCore: async (key) => coreSettings.get(key) ?? null,
},
disposables,
hosts: {
list: async () => [...hostsById.values()],
get: async (hostId) => hostsById.get(hostId) ?? null,
checkAccess: async (hostId): Promise<PluginHostAccess> => {
const found = hostsById.get(hostId);
if (!found)
return { hasAccess: false, isOwner: false, isShared: false };
const isOwner = found.userId === actor;
return {
hasAccess: true,
isOwner,
isShared: !isOwner,
permissionLevel: "manage",
};
},
create: async (
host: PluginHostCreateInput,
): Promise<PluginHostRecord> => {
checkHostInput(host);
const id = nextHostId++;
const record = applyHostInput(
emptyHostRecord(id, actor ?? "unknown"),
host,
);
hostRecordsById.set(id, record);
return record;
},
update: async (
hostId: number,
patch: PluginHostUpdateInput,
): Promise<PluginHostRecord | null> => {
checkHostInput(patch);
const existing = hostRecordsById.get(hostId);
if (!existing) return null;
const updated = applyHostInput(existing, patch);
hostRecordsById.set(hostId, updated);
return updated;
},
delete: async (hostId: number): Promise<boolean> => {
const existed =
hostRecordsById.delete(hostId) || hostsById.delete(hostId);
return existed;
},
listOwned: async (): Promise<PluginHostRecord[]> => [
...hostRecordsById.values(),
],
share: async (
hostId,
targets,
permissionLevel,
durationHours,
): Promise<PluginHostShareResult> => {
hostShares.push({ hostId, targets, permissionLevel, durationHours });
return { hostId, shared: true };
},
listUsers: async () => options.shareableUsers ?? [],
listRoles: async () => options.shareableRoles ?? [],
trackSession: (hostId) => {
trackedSessions.push(hostId);
let active = true;
return () => {
if (!active) return;
active = false;
const index = trackedSessions.indexOf(hostId);
if (index >= 0) trackedSessions.splice(index, 1);
};
},
recordActivity: async (hostId, type, hostName) => {
activities.push({ hostId, type, hostName });
},
status: {
get: async (hostId) => hostStatuses.get(hostId) ?? null,
check: async (hostId) => hostStatuses.get(hostId) ?? null,
reportLogin: (hostId, outcome) => {
statusReports.push({ hostId, ...outcome });
},
registerPort: (connectionType, resolve) => {
statusPorts.set(connectionType, resolve);
return () => {
statusPorts.delete(connectionType);
};
},
},
},
schedule: {
every: (intervalMs, fn, scheduleOptions) => {
const stop = schedule("every", intervalMs, fn);
if (scheduleOptions?.runNow) void fn();
return stop;
},
after: (delayMs, fn) => schedule("after", delayMs, fn),
},
audit: {
record: async (entry) => {
audits.push({ ...entry });
},
},
ssh: {
connect: async (host, connectOptions) => {
sshConnections.push({ host, options: connectOptions });
return {
client: sshClient as never,
jumpClient: null,
host: (typeof host === "number"
? { id: host, ip: "", port: 22, username: "" }
: host) as never,
dispose: () => {},
};
},
withConnection: async (host, connectOptions, fn) => {
sshConnections.push({ host, pool: connectOptions.pool });
return fn(sshClient as never);
},
jumpChain: async () => ({
client: sshClient as never,
jumpClient: null,
host: { id: 0, ip: "", port: 22, username: "" } as never,
dispose: () => {},
}),
dropPooled: (pool, hostId) => {
droppedPools.push({ pool, hostId });
},
poolKey: (pool, host) =>
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`,
resolveHost: async (hostId, resolveOptions) =>
(options.sshHosts ?? []).find((host) =>
resolveOptions?.syncId
? host.syncId === resolveOptions.syncId
: host.id === hostId,
) ?? null,
prepare: async () => ({
config: {},
outcome: { status: "ready" },
authType: null,
}),
openTransport: async () => ({ jumpClient: null, via: "direct" }),
startInteraction: async (interaction, request) => {
interactions.push({ action: "start", interaction, request });
},
cancelInteraction: async (interaction, request) => {
interactions.push({ action: "cancel", interaction, request });
},
classifyKeyboardInteractive: ({ prompts }, host) => ({
kind: "auto",
responses: prompts.map((p) =>
/password/i.test(p.prompt) && typeof host.password === "string"
? host.password
: "",
),
}),
autoResponses: (prompts, password) =>
prompts.map((p) =>
/password/i.test(p.prompt) && password ? password : "",
),
requiresSecret: (authType) =>
["password", "key", "credential", "agent"].includes(authType),
// Matches core: the built-in types, plus any registered provider that
// does not opt out. An unknown type cannot connect unattended.
supportsBackground: (authType) => {
if (["password", "key", "credential", "agent"].includes(authType)) {
return true;
}
const provider = auth.sshAuthProviders.find(
(candidate) => candidate.type === authType,
);
return !!provider && provider.supportsBackground !== false;
},
},
auth: {
registerSshAuthProvider: (provider) => {
auth.sshAuthProviders.push(provider);
},
registerKeyboardInteractiveHandler: (handler) => {
auth.keyboardInteractiveHandlers.push(handler);
},
registerLoginMethod: (method) => {
auth.loginMethods.push(method);
},
registerSecondFactor: (factor) => {
auth.secondFactors.push(factor);
},
recordEnrollment: async (userId, factorId) => {
if (options.refuseEnrollment) {
throw new LoginMethodError(options.refuseEnrollment, 409);
}
auth.enrollments.add(`${userId}:${factorId}`);
},
removeEnrollment: async (userId, factorId) => {
auth.enrollments.delete(`${userId}:${factorId}`);
},
// Stands in for core's pipeline: answers with the identity as JSON, or
// redirects back with the error the way core does.
completeRedirectLogin: async (methodId, req, res) => {
const response = res as FakeResponse;
const method = auth.loginMethods.find(
(candidate) => candidate.id === methodId,
);
if (!method?.callback) {
throw new Error(`No registered redirect method "${methodId}"`);
}
try {
const identity = await method.callback(req as PluginLoginRequest);
auth.completedLogins.push({ methodId, identity });
response.status(200).json({ completed: methodId, identity });
} catch (error) {
const returnTo = (error as { returnTo?: string }).returnTo;
const message = (error as Error).message;
if (returnTo) {
const url = new URL(returnTo);
url.searchParams.set(
"error",
(error as { code?: string }).code ?? message,
);
response.redirect(url.toString());
return;
}
response
.status((error as { status?: number }).status ?? 500)
.json({ error: message });
}
},
revokeSessions: async (match) => {
if (!match.sub && !match.sid) return 0;
auth.revokedSessions.push(match);
return 1;
},
loginRateLimit: {
isLocked: async (ip, key) => {
const failures = auth.loginFailures.get(`${ip}|${key}`) ?? 0;
return failures >= (options.loginAttemptLimit ?? 5)
? { locked: true, remainingTime: 60 }
: { locked: false };
},
recordFailure: async (ip, key) => {
const id = `${ip}|${key}`;
auth.loginFailures.set(id, (auth.loginFailures.get(id) ?? 0) + 1);
},
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
},
desktop: {
openIsolatedWindow: async (request) => {
desktopWindows.push(request);
return { success: true };
},
launchExternalClient: async (request) => {
externalClientLaunches.push(request);
return { success: true };
},
available: () => options.desktopAvailable ?? false,
},
credentials: {
listSshKeys: async () => [
...(options.sshKeyCredentials ?? []),
...createdSshKeys.map((key) => ({
id: key.id,
name: key.name,
username: key.username ?? null,
publicKey: key.publicKey,
})),
],
createSshKey: async (input) => {
const id =
Math.max(
0,
...(options.sshKeyCredentials ?? []).map((key) => key.id),
...createdSshKeys.map((key) => key.id),
) + 1;
createdSshKeys.push({ ...input, id });
return { id };
},
resolveHostProtocol: async (hostId, protocol) => {
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
},
registerSecretResolver: (scheme, resolve) => {
auth.secretResolvers.set(scheme, resolve);
disposals.push(() => {
auth.secretResolvers.delete(scheme);
});
},
},
notify: {
channels: async () => {
if (!actor) throw new Error("ctx.notify needs an acting user");
return options.notificationChannels ?? [];
},
send: async (notification) => {
if (!notification.audience && !actor) {
throw new Error("ctx.notify needs an acting user or an audience");
}
notifications.push({ actor, notification });
const channelIds = notification.channelIds ?? [];
const selected = (options.notificationChannels ?? []).filter(
(channel) => channelIds.includes(channel.id) && channel.enabled,
);
return { recipients: 1, delivered: selected.length, failures: [] };
},
serve: (hub) => {
if (notifyHub.current) throw new Error("A hub is already running");
notifyHub.current = hub;
const revoke = () => {
if (notifyHub.current === hub) notifyHub.current = null;
};
disposals.push(revoke);
return revoke;
},
},
fetch: async (url, init) => {
fetches.push({ url, init });
if (!options.fetch) throw new Error("ctx.fetch is not stubbed");
return options.fetch(url, init);
},
process: {
run: async (file, args, runOptions) => {
processRuns.push({ file, args, options: runOptions });
if (!options.process?.run) {
throw new Error("ctx.process.run is not stubbed");
}
return options.process.run(file, args, runOptions);
},
ensureBinary: async (spec) => {
binaries.push(spec);
if (options.process?.ensureBinary) {
return options.process.ensureBinary(spec);
}
return `/tmp/${pluginId}/bin/${spec.name}`;
},
},
plugins: {
list: async () =>
options.installedPlugins ?? [
{
id: pluginId,
version: "1.0.0",
source: "bundled",
state: "active",
},
],
},
system: {
tlsStatus: async () => {
tls.calls.push("tlsStatus");
return {
...tlsState,
renewal: tls.renewers > 0 ? { pluginId, pluginName: pluginId } : null,
};
},
writeTlsCertificate: async (certificatePem, privateKeyPem) => {
tls.calls.push("writeTlsCertificate");
const info = options.validateTls
? options.validateTls(certificatePem, privateKeyPem)
: {
subject: "CN=fake",
issuer: "CN=fake-ca",
names: [],
notBefore: new Date().toISOString(),
notAfter: new Date(Date.now() + 90 * 86_400_000).toISOString(),
selfSigned: false,
fingerprint: "00",
};
tls.writes.push({ certificatePem, privateKeyPem });
tlsState = { ...tlsState, certificate: info };
return info;
},
reloadTls: async () => {
tls.calls.push("reloadTls");
tls.reloads++;
return { applied: true, message: "reloaded" };
},
publishHttpChallenge: async (token, content) => {
tls.calls.push("publishHttpChallenge");
tls.challenges.set(token, content);
let live = true;
const remove = () => {
if (!live) return;
live = false;
tls.challenges.delete(token);
};
disposals.push(remove);
return remove;
},
registerTlsRenewer: async () => {
tls.calls.push("registerTlsRenewer");
tls.renewers++;
let live = true;
const remove = () => {
if (!live) return;
live = false;
tls.renewers--;
};
disposals.push(remove);
return remove;
},
},
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
currentActor: () => actor,
};
return {
ctx,
validateSettings: async (scope, values, context = {}) => {
const errors: Record<string, string> = {};
for (const entry of settingsValidators) {
if (entry.scope !== scope) continue;
Object.assign(errors, (await entry.validator(values, context)) ?? {});
}
return errors;
},
disposals,
emitted,
kv,
tables,
syncEntities,
wsRoutes,
httpRouters,
settings,
coreSettings,
sshConnections,
hostShares,
auth,
desktopWindows,
externalClientLaunches,
credentialReads,
createdSshKeys,
notifications,
notifyHub,
fetches,
processRuns,
binaries,
tls,
secretStore,
audits,
activities,
trackedSessions,
interactions,
statusReports,
statusPorts,
hostStatuses,
droppedPools,
scheduled,
runScheduled: async () => {
for (const job of scheduled.filter((candidate) => !candidate.stopped)) {
if (job.kind === "after") job.stopped = true;
await job.fn();
}
},
setActor: (userId) => {
actor = userId;
},
actAs: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
services,
};
}
export interface MockContextOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Capabilities the plugin is granted. Anything else throws. */
capabilities?: string[];
/** Seed for ctx.kv. */
kv?: Record<string, unknown>;
/** Seed for this plugin's own admin-scope settings. */
settings?: Record<string, unknown>;
/** Seed for the core settings readCore can reach. */
coreSettings?: Record<string, string>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
/** A real database behind ctx.db, usually createTestDb().database. */
db?: PluginDatabase;
/** What ctx.http.router returns, e.g. () => express.Router(). */
router?: () => unknown;
/** Role permissions the acting user holds. See FakeContextOptions. */
permissions?: string[];
/** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */
hosts?: PluginHostSummary[];
/** Users and roles ctx.hosts.listUsers/listRoles serve. See FakeContextOptions. */
shareableUsers?: PluginShareableUser[];
shareableRoles?: PluginShareableRole[];
/** Other plugins' services. See FakeContextOptions. */
services?: Record<string, object>;
/** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */
sshHosts?: PluginSshHost[];
/** What ctx.credentials serves. See FakeContextOptions. */
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys serves. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
/** What ctx.hosts.status answers. See FakeContextOptions. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
* Answers ctx.fetch. Without it every fetch rejects, so a test never
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/** See FakeContextOptions. */
process?: FakeProcessOptions;
/** See FakeContextOptions. */
tlsStatus?: FakeContextOptions["tlsStatus"];
/** See FakeContextOptions. */
installedPlugins?: FakeContextOptions["installedPlugins"];
/** See FakeContextOptions. */
validateTls?: FakeContextOptions["validateTls"];
/** See FakeContextOptions. */
refuseEnrollment?: string;
/** See FakeContextOptions. */
baseUrl?: string;
/** See FakeContextOptions. */
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
}
export interface MockPluginContext extends FakePluginContext {
/** Capability ids checked during the test, in order, denied ones included. */
checked: string[];
}
/**
* A context double that gates on capabilities the way src/backend/plugins/ctx.ts
* does, so a test can prove a plugin fails closed without a running server.
*
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
options: MockContextOptions = {},
): MockPluginContext {
const granted = new Set(options.capabilities ?? []);
const checked: string[] = [];
const base = createFakeContext({
pluginId: options.pluginId,
actor: options.actor,
settings: options.settings,
coreSettings: options.coreSettings,
sshClient: options.sshClient,
db: options.db,
router: options.router,
permissions: options.permissions,
hosts: options.hosts,
shareableUsers: options.shareableUsers,
shareableRoles: options.shareableRoles,
sshHosts: options.sshHosts,
services: options.services,
protocolTargets: options.protocolTargets,
sshKeyCredentials: options.sshKeyCredentials,
desktopAvailable: options.desktopAvailable,
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
process: options.process,
tlsStatus: options.tlsStatus,
installedPlugins: options.installedPlugins,
validateTls: options.validateTls,
refuseEnrollment: options.refuseEnrollment,
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
},
});
const { ctx, kv } = base;
const pluginId = ctx.pluginId;
for (const [key, value] of Object.entries(options.kv ?? {})) {
kv.set(key, value);
}
const require = (capability: string) => {
checked.push(capability);
if (!granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
};
const guardedKv = ctx.kv;
const guardedDb = ctx.db;
const gatedCtx: PluginContext = {
...ctx,
asUser: async (userId, fn) => {
require("users:impersonate");
return ctx.asUser(userId, fn);
},
db: {
define: async (definition) => {
require("db:own");
return guardedDb.define(definition);
},
client: async () => {
require("db:own");
return guardedDb.client();
},
refs: async () => {
require("db:core-refs");
return guardedDb.refs();
},
persist: async (persistOptions) => {
require("db:own");
return guardedDb.persist(persistOptions);
},
dialect: guardedDb.dialect,
},
kv: {
get: async (key) => {
require("kv:own");
return guardedKv.get(key);
},
set: async (key, value) => {
require("kv:own");
return guardedKv.set(key, value);
},
delete: async (key) => {
require("kv:own");
return guardedKv.delete(key);
},
list: async () => {
require("kv:own");
return guardedKv.list();
},
},
files: {
dataDir: async () => {
require("files:own");
return ctx.files.dataDir();
},
},
events: {
emit: (topic, payload) => {
if (
!topic.startsWith(`plugin.${pluginId}.`) &&
!granted.has("events:core")
) {
throw new Error(
`Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` +
`Declare the events:core capability to emit core topics.`,
);
}
ctx.events.emit(topic, payload);
},
on: (topic, listener) => {
if (!topic.startsWith("plugin.") && !granted.has("events:core")) {
throw new Error(
`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
`Declare the events:core capability to listen to core topics.`,
);
}
return ctx.events.on(topic, listener);
},
},
http: {
router: (routerOptions) => {
require("network:serve");
return ctx.http.router(routerOptions);
},
baseUrl: (req) => ctx.http.baseUrl(req),
},
ws: {
route: (path, handler, wsOptions) => {
require("network:serve");
ctx.ws.route(path, handler, wsOptions);
},
upgrade: (path, handler, wsOptions) => {
require("network:serve");
ctx.ws.upgrade(path, handler, wsOptions);
},
},
settings: {
// Reading and writing a plugin's OWN settings is ungated, exactly as in
// the real runtime. Only readCore reaches outside the plugin.
...ctx.settings,
listHostValues: async (key) => {
require("hosts:read");
return ctx.settings.listHostValues(key);
},
readCore: async (key) => {
require("settings:read-core");
return ctx.settings.readCore(key);
},
},
hosts: {
list: async () => {
require("hosts:read");
return ctx.hosts.list();
},
get: async (hostId) => {
require("hosts:read");
return ctx.hosts.get(hostId);
},
checkAccess: async (hostId, level) => {
require("hosts:read");
return ctx.hosts.checkAccess(hostId, level);
},
create: async (host) => {
require("hosts:write");
return ctx.hosts.create(host);
},
update: async (hostId, patch) => {
require("hosts:write");
return ctx.hosts.update(hostId, patch);
},
delete: async (hostId) => {
require("hosts:write");
return ctx.hosts.delete(hostId);
},
listOwned: async () => {
require("hosts:write");
return ctx.hosts.listOwned();
},
share: async (hostId, targets, permissionLevel, durationHours) => {
require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
},
listUsers: async () => {
require("hosts:write");
return ctx.hosts.listUsers();
},
listRoles: async () => {
require("hosts:write");
return ctx.hosts.listRoles();
},
trackSession: (hostId) => {
require("hosts:read");
return ctx.hosts.trackSession(hostId);
},
recordActivity: async (hostId, type, hostName) => {
require("hosts:read");
return ctx.hosts.recordActivity(hostId, type, hostName);
},
status: {
get: async (hostId) => {
require("hosts:read");
return ctx.hosts.status.get(hostId);
},
check: async (hostId) => {
require("hosts:read");
return ctx.hosts.status.check(hostId);
},
reportLogin: (hostId, outcome) => {
require("hosts:read");
ctx.hosts.status.reportLogin(hostId, outcome);
},
registerPort: (connectionType, resolve) => {
require("hosts:read");
return ctx.hosts.status.registerPort(connectionType, resolve);
},
},
},
ssh: {
...ctx.ssh,
connect: async (host, connectOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.connect(host, connectOptions);
},
withConnection: async (host, connectOptions, fn) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.withConnection(host, connectOptions, fn);
},
prepare: async (host, prepareOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.prepare(host, prepareOptions);
},
openTransport: async (host, config, transportOptions) => {
require("ssh:connect");
return ctx.ssh.openTransport(host, config, transportOptions);
},
resolveHost: async (hostId, resolveOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.resolveHost(hostId, resolveOptions);
},
startInteraction: async (interaction, request) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.startInteraction(interaction, request);
},
cancelInteraction: async (interaction, request) => {
require("ssh:connect");
return ctx.ssh.cancelInteraction(interaction, request);
},
jumpChain: async (jumpHosts, chainOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.jumpChain(jumpHosts, chainOptions);
},
},
auth: {
registerSshAuthProvider: (provider) => {
require("auth:provide");
ctx.auth.registerSshAuthProvider(provider);
},
registerKeyboardInteractiveHandler: (handler) => {
require("auth:provide");
ctx.auth.registerKeyboardInteractiveHandler(handler);
},
registerLoginMethod: (method) => {
require("auth:provide");
ctx.auth.registerLoginMethod(method);
},
registerSecondFactor: (factor) => {
require("auth:provide");
ctx.auth.registerSecondFactor(factor);
},
recordEnrollment: async (userId, factorId) => {
require("auth:provide");
return ctx.auth.recordEnrollment(userId, factorId);
},
removeEnrollment: async (userId, factorId) => {
require("auth:provide");
return ctx.auth.removeEnrollment(userId, factorId);
},
completeRedirectLogin: async (methodId, req, res) => {
require("auth:provide");
return ctx.auth.completeRedirectLogin(methodId, req, res);
},
revokeSessions: async (match) => {
require("auth:provide");
return ctx.auth.revokeSessions(match);
},
loginRateLimit: {
isLocked: async (ip, key) => {
require("auth:provide");
return ctx.auth.loginRateLimit.isLocked(ip, key);
},
recordFailure: async (ip, key) => {
require("auth:provide");
return ctx.auth.loginRateLimit.recordFailure(ip, key);
},
},
countLinkedUsers: async (provider) => {
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
},
desktop: {
openIsolatedWindow: async (request) => {
require("desktop:window");
return ctx.desktop.openIsolatedWindow(request);
},
launchExternalClient: async (request) => {
require("desktop:window");
return ctx.desktop.launchExternalClient(request);
},
available: () => ctx.desktop.available(),
},
credentials: {
listSshKeys: async () => {
require("credentials:use");
return ctx.credentials.listSshKeys();
},
createSshKey: async (input) => {
require("credentials:write");
return ctx.credentials.createSshKey(input);
},
resolveHostProtocol: async (hostId, protocol) => {
require("credentials:read");
const declared = ctx.manifest.contributes?.protocols;
if (declared && !declared.some((entry) => entry.id === protocol)) {
throw new Error(
`Plugin ${pluginId} did not declare protocol "${protocol}" in contributes.protocols`,
);
}
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
registerSecretResolver: (scheme, resolve) => {
require("auth:provide");
ctx.credentials.registerSecretResolver(scheme, resolve);
},
},
notify: {
channels: async () => {
require("notify:send");
return ctx.notify.channels();
},
send: async (notification) => {
require("notify:send");
return ctx.notify.send(notification);
},
serve: (hub) => {
require("notify:hub");
return ctx.notify.serve(hub);
},
},
fetch: async (url, init) => {
require("network:outbound");
return ctx.fetch(url, init);
},
process: {
run: async (file, args, runOptions) => {
require("process:spawn");
return ctx.process.run(file, args, runOptions);
},
ensureBinary: async (spec) => {
require("process:spawn");
return ctx.process.ensureBinary(spec);
},
},
plugins: {
list: async () => {
require("plugins:read");
return ctx.plugins.list();
},
},
system: {
tlsStatus: async () => {
require("system:tls");
return ctx.system.tlsStatus();
},
writeTlsCertificate: async (certificatePem, privateKeyPem) => {
require("system:tls");
return ctx.system.writeTlsCertificate(certificatePem, privateKeyPem);
},
reloadTls: async () => {
require("system:tls");
return ctx.system.reloadTls();
},
publishHttpChallenge: async (token, content) => {
require("system:tls");
return ctx.system.publishHttpChallenge(token, content);
},
registerTlsRenewer: async () => {
require("system:tls");
return ctx.system.registerTlsRenewer();
},
},
secrets: {
...ctx.secrets,
get: async (key) => {
require("secrets:own");
return ctx.secrets.get(key);
},
set: async (key, value) => {
require("secrets:own");
return ctx.secrets.set(key, value);
},
delete: async (key) => {
require("secrets:own");
return ctx.secrets.delete(key);
},
seal: async (value) => {
require("secrets:own");
return ctx.secrets.seal(value);
},
unseal: async (sealed) => {
require("secrets:own");
return ctx.secrets.unseal(sealed);
},
},
capabilities: {
has: async (capability) => granted.has(capability),
require: async (capability) => {
require(capability);
},
},
};
return { ...base, ctx: gatedCtx, checked };
}
/** A sqlite handle as createTestDb exposes it. Structural, like better-sqlite3's. */
export interface TestSqlite {
exec: (sql: string) => unknown;
prepare: (sql: string) => {
run: (...params: unknown[]) => unknown;
get: (...params: unknown[]) => unknown;
all: (...params: unknown[]) => unknown[];
};
close: () => void;
}
export interface TestDbOptions {
/**
* Runs after the core stub tables exist and before the plugin's migrations,
* e.g. to create the legacy table an adoption migration renames.
*/
before?: (sqlite: TestSqlite) => unknown;
/** Skip applying the migrations, to apply them by hand later. */
skipMigrations?: boolean;
}
export interface TestDb {
/** The raw better-sqlite3 handle, for asserting on tables directly. */
sqlite: TestSqlite;
/** The Drizzle handle ctx.db.client() returns. */
drizzle: unknown;
/** Pass this as createMockCtx({ db }). */
database: PluginDatabase;
/** How many times ctx.db.persist() was called. */
readonly persisted: number;
/** The migration ids applied, in order. */
applied: string[];
/** Applies the plugin's sqlite migrations, when skipMigrations was set. */
migrate: () => Promise<string[]>;
close: () => void;
}
/**
* An in-memory SQLite database with a plugin's own migrations applied.
*
* Core's users, ssh_data, roles and user_roles exist as minimal stubs with
* foreign keys on, so a refUser or refHost column cascades exactly as it
* does on the server, and ctx.db.refs() has something real to join against.
* The migrations are read from <pluginDir>/migrations/sqlite and split with
* the same splitter the server's runner uses.
*/
export async function createTestDb(
pluginDir: string,
options: TestDbOptions = {},
): Promise<TestDb> {
const [{ default: Database }, { drizzle }, fs, path, ddl, builder] =
await Promise.all([
import("better-sqlite3" as string),
import("drizzle-orm/better-sqlite3" as string),
import("node:fs"),
import("node:path"),
import("./ddl.js"),
import("./table-builder.js"),
]);
const sqlite = new Database(":memory:") as TestSqlite;
sqlite.exec("PRAGMA foreign_keys = ON");
sqlite.exec(
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '', is_admin INTEGER NOT NULL DEFAULT 0)",
);
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
sqlite.exec(
"CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '')",
);
sqlite.exec(
"CREATE TABLE user_roles (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE)",
);
const handle = drizzle(sqlite);
// The manifest id, not the folder name: a plugin's own repo is rarely
// named after its id.
const manifestPath = path.join(pluginDir, "manifest.json");
const pluginId: string =
(fs.existsSync(manifestPath) &&
JSON.parse(fs.readFileSync(manifestPath, "utf8")).id) ||
path.basename(pluginDir);
const applied: string[] = [];
let persisted = 0;
await options.before?.(sqlite);
const migrate = async () => {
const dir = path.join(pluginDir, "migrations", "sqlite");
if (!fs.existsSync(dir)) return [];
const files = fs
.readdirSync(dir)
.filter((file: string) => /^\d{4}_[a-z0-9_]+\.sql$/.test(file))
.sort();
const now: string[] = [];
for (const file of files) {
const id = file.replace(/\.sql$/, "");
if (applied.includes(id)) continue;
const source = fs.readFileSync(path.join(dir, file), "utf8");
for (const statement of ddl.splitStatements(source)) {
sqlite.exec(statement);
}
applied.push(id);
now.push(id);
}
return now;
};
if (!options.skipMigrations) await migrate();
const refs = {
users: builder.buildRefTable("users", {
id: "text",
username: "text",
isAdmin: "boolean",
}),
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
roles: builder.buildRefTable("roles", {
id: "integer",
name: "text",
displayName: "text",
}),
userRoles: builder.buildRefTable("user_roles", {
id: "integer",
userId: "text",
roleId: "integer",
}),
};
const database: PluginDatabase = {
define: async (definition) =>
builder.buildTable(pluginId, definition) as never,
client: async () => handle as never,
refs: async () => refs as never,
persist: async () => {
persisted += 1;
},
dialect: "sqlite",
};
return {
sqlite,
drizzle: handle,
database,
get persisted() {
return persisted;
},
applied,
migrate,
close: () => sqlite.close(),
};
}
/**
* Rendering a plugin frontend in a test.
*
* renderWithApp activates the plugin against the real registration surface,
* so a test sees exactly what the shell would: which rail items, tabs, host
* actions and cards it registered, and each of those rendered with the SDK
* hooks working. It is implemented by the Termix host, which is where the
* registries live; the vitest preset points "@termix-ssh/plugin-host/testing" at
* core's implementation.
*/
export interface RenderWithAppOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Role permissions the current user holds. */
permissions?: string[];
isAdmin?: boolean;
/** Hosts useHosts() returns. */
hosts?: Array<Record<string, unknown>>;
/** The plugin's locales/en.json, loaded into its namespace. */
locales?: Record<string, unknown>;
/** Render as an anonymous guest page. */
guest?: boolean;
/** What app.tabs.getLayout returns until the plugin applies another. */
layout?: import("./frontend.js").ShellLayout;
/** Fire app.tabs.onReady after activation, as the shell does after login. */
ready?: boolean;
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
api?: import("./frontend.js").PluginApiClient;
/** What app.desktop.remoteServerUrl() answers. Read on every call. */
remoteServerUrl?: () => string | null | Promise<string | null>;
}
/** A call a plugin made on the shell, recorded instead of performed. */
export interface ShellCall {
method: string;
args: unknown[];
}
export interface RenderedPluginApp {
app: TermixApp;
/** Everything this plugin registered, by kind. */
registered: {
railItems: () => Array<{
id: string;
hidden?: boolean;
/** The full permission id the item is gated on, if any. */
permission?: string;
}>;
tabs: () => string[];
panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>;
/** This plugin's host actions that apply to `host`, as the shell sees them. */
hostActionsFor: (host: unknown) => HostActionContribution[];
hostProtocols: () => string[];
hostEditorSections: () => string[];
dashboardCards: () => string[];
/** Ids this plugin added to an extension point. */
extensions: (pointId: string) => string[];
settingsComponents: () => string[];
slot: (slotId: string) => string[];
actions: () => string[];
loginMethods: () => string[];
secondFactors: () => string[];
paletteGroups: () => string[];
keybindingActions: () => string[];
keybindingDefaults: () => string[];
/** Onboarding step ids, without the plugin prefix. */
onboardingSteps: () => string[];
};
/** A registered palette group's items, as the palette loads them on open. */
loadPaletteGroup: (
id: string,
) => Promise<import("./frontend.js").PaletteItem[]>;
/** Shell calls made by the plugin's code, applyLayout included. */
shellCalls: ShellCall[];
/**
* Tabs the last app.tabs.applyLayout opened, under the shell's own restore
* rules: a tab whose host is gone is skipped, a tab no running plugin
* registered is kept.
*/
openedTabs: () => Array<{ type: string; hostId?: number; label: string }>;
/**
* Renders an opened tab the way the shell would, which for a tab no running
* plugin registered is the "needs the plugin" placeholder.
*/
renderOpenedTab: (index: number) => HTMLElement;
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
renderDashboardCard: (id: string) => HTMLElement;
/**
* Renders an onboarding step with OnboardingStepProps for a first run,
* overridable. Read what the step set with the returned `canContinue()`
* and `next()` (runs its beforeNext, true when it has none).
*/
renderOnboardingStep: (
id: string,
props?: Record<string, unknown>,
) => {
element: HTMLElement;
canContinue: () => boolean;
next: () => Promise<boolean>;
};
/** Renders one component of a contributed extension, "view" by default. */
renderExtension: (
pointId: string,
id: string,
component?: string,
props?: Record<string, unknown>,
) => HTMLElement;
renderHostEditorSection: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
renderSettingsComponent: (
componentId: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders a login method's UI with login screen props, overridable. */
renderLoginMethod: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders a second factor's challenge with overridable props. */
renderSecondFactor: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders the Settings > Security section a factor or method brought. */
renderEnrollment: (id: string) => HTMLElement;
/** Renders a slot the way its owner would. */
renderSlot: (slotId: string, props?: Record<string, unknown>) => HTMLElement;
/** Runs deactivate and every disposer, as disabling the plugin does. */
deactivate: () => Promise<void>;
}
export type FrontendPluginModule = {
activate: (app: TermixApp) => void | Promise<void>;
deactivate?: () => void | Promise<void>;
};
interface PluginTestHost {
renderPlugin: (
plugin: FrontendPluginModule,
options: RenderWithAppOptions,
) => Promise<RenderedPluginApp>;
}
const TEST_HOST = "@termix-ssh/plugin-host/testing";
export async function renderWithApp(
plugin: FrontendPluginModule,
options: RenderWithAppOptions = {},
): Promise<RenderedPluginApp> {
// A variable specifier keeps bundlers from resolving it at build time; the
// test runner resolves it through its alias.
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
return host.renderPlugin(plugin, options);
}
function assertOwnRegistryKey(pluginId: string, key: string): void {
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
throw new Error(
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
);
}
}