Files
Termix/packages/plugin-sdk/src/testing.ts
T

2153 lines
69 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/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 {
PluginNativeRdpRequest,
PluginProtocolTarget,
PluginHostStatusEntry,
PluginNotification,
PluginNotificationChannel,
PluginNotifyHub,
PluginFetchInit,
PluginBinarySpec,
PluginTlsCertificateInfo,
PluginTlsStatus,
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">;
/**
* 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.launchNativeRdp call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[];
/** 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;
/** 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 nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
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) => {
registryProviders.set(key, value);
},
consume: (key) => registryProviders.get(key) as never,
revoke: (key, value) => {
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,
setHost: async (hostId, key, value) =>
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 };
},
launchNativeRdp: async (request) => {
nativeRdpLaunches.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}`;
},
},
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,
nativeRdpLaunches,
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;
},
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. */
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, 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,
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,
db: {
define: async (definition) => {
require("db:own");
return guardedDb.define(definition);
},
client: async () => {
require("db:own");
return guardedDb.client();
},
refs: async () => {
require("db:own");
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);
},
launchNativeRdp: async (request) => {
require("desktop:window");
return ctx.desktop.launchNativeRdp(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);
},
},
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) => void | Promise<void>;
/** 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/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;
}
/** 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[];
settingsComponents: () => string[];
slot: (slotId: string) => string[];
actions: () => string[];
loginMethods: () => string[];
secondFactors: () => string[];
paletteGroups: () => string[];
keybindingActions: () => string[];
keybindingDefaults: () => 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;
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/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);
}