2026-10-01 16:20:16 -05:00
/**
* 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.
*
2026-10-10 14:58:27 -05:00
* The vitest config these run under comes from @termix-ssh/plugin-sdk/vitest-preset.
2026-10-01 16:20:16 -05:00
*/
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
2026-10-10 14:58:27 -05:00
* registries live; the vitest preset points "@termix-ssh/plugin-host/testing" at
2026-10-01 16:20:16 -05:00
* 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 [];
2026-10-10 14:58:27 -05:00
/** Onboarding step ids, without the plugin prefix. */
onboardingSteps : () => string [];
2026-10-01 16:20:16 -05:00
};
/** 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 ;
2026-10-10 14:58:27 -05:00
/**
* 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 >;
};
2026-10-01 16:20:16 -05:00
/** 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 >;
}
2026-10-10 14:58:27 -05:00
const TEST_HOST = "@termix-ssh/plugin-host/testing" ;
2026-10-01 16:20:16 -05:00
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 } "` ,
);
}
}