mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move snippets, keybinding actions and macros out of core into plugins
This commit is contained in:
1 parent
d9bba10612
commit
e0c30a4e6f
262 files changed
+9091
-10744
No files matched your search
@@ -305,10 +305,17 @@ describe("transformHostResponse", () => {
|
||||
it("parses JSON array fields and defaults them to []", () => {
|
||||
const result = transformHostResponse({
|
||||
jumpHosts: '[{"hostId":8}]',
|
||||
quickActions: null,
|
||||
portKnockSequence: null,
|
||||
});
|
||||
expect(result.jumpHosts).toEqual([{ hostId: 8 }]);
|
||||
expect(result.quickActions).toEqual([]);
|
||||
expect(result.portKnockSequence).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the 2.8 quick_actions column out, for its plugin to put back", () => {
|
||||
const result = transformHostResponse({
|
||||
quickActions: '[{"name":"x","snippetId":1}]',
|
||||
});
|
||||
expect(result.quickActions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes the stored SSH switch through", () => {
|
||||
@@ -440,10 +447,7 @@ describe("transformHostResponse terminal fields", () => {
|
||||
}),
|
||||
});
|
||||
expect(host.sshOptions).toEqual({ keepaliveInterval: 20 });
|
||||
expect(host.terminalConfig).toEqual({
|
||||
keepaliveInterval: 20,
|
||||
startupSnippetId: 3,
|
||||
});
|
||||
expect(host.terminalConfig).toEqual({ keepaliveInterval: 20 });
|
||||
});
|
||||
|
||||
it("reads the options out of terminal_config before the boot copy", () => {
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { afterEach, beforeEach, describe, it, expect } from "vitest";
|
||||
import {
|
||||
isValidKeyCombo,
|
||||
isValidKeybindingAction,
|
||||
isValidKeybinding,
|
||||
setKeybindingActionSource,
|
||||
} from "../../../database/routes/keybinding-validation.js";
|
||||
|
||||
// What installed plugins declare in contributes.keybindingActions.
|
||||
beforeEach(() => {
|
||||
setKeybindingActionSource(() => [
|
||||
{ pluginId: "terminal-fixture", id: "copy" },
|
||||
{ pluginId: "terminal-fixture", id: "paste" },
|
||||
{
|
||||
pluginId: "terminal-fixture",
|
||||
id: "sendControlCode",
|
||||
params: {
|
||||
controlCode: { type: "string", required: true, pattern: "^[a-zA-Z]$" },
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "terminal-fixture",
|
||||
id: "sendText",
|
||||
params: {
|
||||
text: { type: "string", required: true },
|
||||
appendEnter: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "commands-fixture",
|
||||
id: "runSnippet",
|
||||
params: {
|
||||
snippetId: { type: "string", required: true, pattern: "^[0-9]+$" },
|
||||
appendEnter: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => setKeybindingActionSource(() => []));
|
||||
|
||||
const validCombo = {
|
||||
key: "c",
|
||||
isCode: false,
|
||||
@@ -36,15 +70,26 @@ describe("isValidKeybindingAction", () => {
|
||||
expect(isValidKeybindingAction({ type: "paste" })).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["nextTab", "previousTab", "openCommandPalette"])(
|
||||
it.each(["nextTab", "previousTab", "openCommandPalette", "reconnectSession"])(
|
||||
"accepts the global %s action",
|
||||
(type) => {
|
||||
expect(isValidKeybindingAction({ type })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an unknown action type", () => {
|
||||
expect(isValidKeybindingAction({ type: "explode" })).toBe(false);
|
||||
it("keeps a type no installed plugin declares, if it looks sane", () => {
|
||||
expect(isValidKeybindingAction({ type: "gone.action", arg: "x" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isValidKeybindingAction({ type: "gone", nested: { a: 1 } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isValidKeybindingAction({ type: "bad type!" })).toBe(false);
|
||||
expect(isValidKeybindingAction({ type: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a parameter the declaration does not list", () => {
|
||||
expect(isValidKeybindingAction({ type: "copy", text: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("requires text for sendText", () => {
|
||||
@@ -72,6 +117,9 @@ describe("isValidKeybindingAction", () => {
|
||||
isValidKeybindingAction({ type: "runSnippet", snippetId: "42" }),
|
||||
).toBe(true);
|
||||
expect(isValidKeybindingAction({ type: "runSnippet" })).toBe(false);
|
||||
expect(
|
||||
isValidKeybindingAction({ type: "runSnippet", snippetId: "abc" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+28
@@ -199,7 +199,9 @@ const HOST_ROWS: Row[] = [
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
startupSnippetId: 1,
|
||||
}),
|
||||
quick_actions: JSON.stringify([{ name: "d4-uptime", snippetId: 1 }]),
|
||||
mac_address: "AA:BB:CC:DD:EE:04",
|
||||
wol_broadcast_address: "10.4.0.255",
|
||||
stats_config: JSON.stringify({
|
||||
@@ -387,6 +389,32 @@ export const ROWS: Record<string, Row[]> = {
|
||||
},
|
||||
]),
|
||||
command_autocomplete: 1,
|
||||
confirm_snippet_execution: 1,
|
||||
folders_collapsed: 0,
|
||||
terminal_macros: JSON.stringify([
|
||||
{
|
||||
id: "d4-macro",
|
||||
name: "d4 macro",
|
||||
steps: [{ id: "s1", type: "send", text: "uptime", pressEnter: true }],
|
||||
},
|
||||
]),
|
||||
custom_keybindings: JSON.stringify([
|
||||
{
|
||||
id: "d4-kb",
|
||||
combo: {
|
||||
key: "u",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
shift: false,
|
||||
meta: false,
|
||||
},
|
||||
action: { type: "runSnippet", snippetId: "1", appendEnter: true },
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
sso_providers: [
|
||||
|
||||
@@ -686,3 +686,76 @@ describe("contributes.http.legacyRedirects", () => {
|
||||
expect(errors).toMatch(/extra/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.http.adminImpersonation", () => {
|
||||
it("accepts a boolean and refuses anything else", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { http: { adminImpersonation: true } } }),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
base({ contributes: { http: { adminImpersonation: "yes" } } }),
|
||||
).join(),
|
||||
).toMatch(/adminImpersonation" must be a boolean/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contributes.keybindingActions", () => {
|
||||
const withActions = (keybindingActions: unknown) =>
|
||||
validateManifest(base({ contributes: { keybindingActions } }));
|
||||
|
||||
it("accepts actions with typed parameters", () => {
|
||||
expect(
|
||||
withActions([
|
||||
{ id: "sample.run" },
|
||||
{
|
||||
id: "sample.send",
|
||||
params: {
|
||||
itemId: {
|
||||
type: "string",
|
||||
required: true,
|
||||
pattern: "^[0-9]+$",
|
||||
maxLength: 20,
|
||||
syncEntity: "items",
|
||||
},
|
||||
enter: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the shell's own actions, duplicates and bad ids", () => {
|
||||
const errors = withActions([
|
||||
{ id: "nextTab" },
|
||||
{ id: "sample.run" },
|
||||
{ id: "sample.run" },
|
||||
{ id: "bad id" },
|
||||
]).join();
|
||||
expect(errors).toMatch(/one of the shell's own actions/);
|
||||
expect(errors).toMatch(/duplicates "sample.run"/);
|
||||
expect(errors).toMatch(/letters, digits, dots and dashes/);
|
||||
});
|
||||
|
||||
it("refuses bad parameters", () => {
|
||||
const errors = withActions([
|
||||
{
|
||||
id: "sample.run",
|
||||
params: {
|
||||
type: { type: "string" },
|
||||
a: { type: "number" },
|
||||
b: { type: "string", pattern: "(" },
|
||||
c: { type: "boolean", syncEntity: "items" },
|
||||
d: { type: "string", extra: 1 },
|
||||
},
|
||||
},
|
||||
]).join();
|
||||
expect(errors).toMatch(/params.type is not a valid parameter name/);
|
||||
expect(errors).toMatch(/a.type must be "string" or "boolean"/);
|
||||
expect(errors).toMatch(/b.pattern must be a regular expression/);
|
||||
expect(errors).toMatch(/c.syncEntity needs a string parameter/);
|
||||
expect(errors).toMatch(/d/);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
resetSyncRegistry,
|
||||
unregisterByOwner,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { registerCoreSyncEntities } from "../../sync/entities.js";
|
||||
import {
|
||||
mapKeybindingReferences,
|
||||
registerCoreSyncEntities,
|
||||
} from "../../sync/entities.js";
|
||||
import { setKeybindingActionSource } from "../../database/routes/keybinding-validation.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
@@ -197,14 +201,51 @@ describe("core sync entities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("translates every snippet a host points at", () => {
|
||||
const fields = (getEntity("hosts")?.references ?? [])
|
||||
.filter((reference) => reference.entityType === "commandSnippet")
|
||||
.map((reference) => reference.field);
|
||||
expect(fields).toEqual([
|
||||
"quickActions[].snippetId",
|
||||
"terminalConfig.startupSnippetId",
|
||||
it("leaves plugin data out of the host row, which travels in pluginSettings", () => {
|
||||
const hosts = getEntity("hosts");
|
||||
expect(
|
||||
(hosts?.references ?? []).map((reference) => reference.entityType),
|
||||
).not.toContain("commandSnippet");
|
||||
expect(hosts?.readOnlyFields).toContain("quickActions");
|
||||
});
|
||||
|
||||
it("translates a keybinding parameter that names a sync entity", async () => {
|
||||
setKeybindingActionSource(() => [
|
||||
{
|
||||
pluginId: "fixture",
|
||||
id: "runThing",
|
||||
params: {
|
||||
thingId: { type: "string", syncEntity: "things" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const row = {
|
||||
customKeybindings: JSON.stringify([
|
||||
{ id: "a", action: { type: "runThing", thingId: "4", label: "4" } },
|
||||
{ id: "b", action: { type: "nextTab" } },
|
||||
]),
|
||||
};
|
||||
const out = await mapKeybindingReferences(row, async (entity, value) =>
|
||||
entity === "things" && value === "4" ? "sync-4" : null,
|
||||
);
|
||||
const bindings = JSON.parse(out.customKeybindings as string);
|
||||
expect(bindings[0].action).toEqual({
|
||||
type: "runThing",
|
||||
thingId: "sync-4",
|
||||
label: "4",
|
||||
});
|
||||
expect(bindings[1].action).toEqual({ type: "nextTab" });
|
||||
expect(
|
||||
await mapKeybindingReferences(
|
||||
{ customKeybindings: null },
|
||||
async () => "x",
|
||||
),
|
||||
).toEqual({ customKeybindings: null });
|
||||
} finally {
|
||||
setKeybindingActionSource(() => []);
|
||||
}
|
||||
});
|
||||
|
||||
it("encrypts host and credential secrets", () => {
|
||||
|
||||
@@ -326,6 +326,10 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
containerRuntime: "podman",
|
||||
});
|
||||
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
|
||||
expect(await host("snippets")).toMatchObject({
|
||||
startupSnippetId: 1,
|
||||
quickActions: [{ name: "d4-uptime", snippetId: 1 }],
|
||||
});
|
||||
expect(await host("wake-on-lan")).toMatchObject({
|
||||
macAddress: "AA:BB:CC:DD:EE:04",
|
||||
broadcastAddress: "10.4.0.255",
|
||||
@@ -473,6 +477,11 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
terminalDefaults: { fontSize: 18, cursorBlink: false },
|
||||
customThemes: [expect.objectContaining({ id: "d4-theme" })],
|
||||
commandAutocomplete: true,
|
||||
macros: [expect.objectContaining({ id: "d4-macro", name: "d4 macro" })],
|
||||
});
|
||||
expect(await settings(booted, "snippets", "user")).toMatchObject({
|
||||
confirmExecution: true,
|
||||
foldersCollapsed: false,
|
||||
});
|
||||
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
|
||||
caUrl: "https://ca.d4.example",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The snippets settings migration: host quick actions, the startup snippet
|
||||
* and the two snippet user preferences must come through into the snippets
|
||||
* plugin's settings, and running it twice must change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const pluginRows: Row[] = [];
|
||||
const hostRows: Array<Record<string, unknown>> = [];
|
||||
const preferenceRows: Array<Record<string, unknown>> = [];
|
||||
let pluginInstalled = true;
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === "snippets" &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
pluginInstalled && id === "snippets" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async (query: unknown) =>
|
||||
JSON.stringify(query).includes("user_preferences")
|
||||
? preferenceRows
|
||||
: hostRows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runSnippetsSettingsMigration, snippetHostSettingsFromRow } =
|
||||
await import("../../upgrade/snippets-settings-migration.js");
|
||||
|
||||
function value(scope: string, scopeId: string, key: string): unknown {
|
||||
const row = find(scope, scopeId, key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pluginRows.length = 0;
|
||||
hostRows.length = 0;
|
||||
preferenceRows.length = 0;
|
||||
pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("snippetHostSettingsFromRow", () => {
|
||||
it("reads quick actions and the startup snippet, dropping broken rows", () => {
|
||||
expect(
|
||||
snippetHostSettingsFromRow({
|
||||
quick_actions: JSON.stringify([
|
||||
{ name: "Restart", snippetId: 3 },
|
||||
{ name: "Broken", snippetId: "x" },
|
||||
{ name: "Str", snippetId: "4" },
|
||||
]),
|
||||
terminal_config: JSON.stringify({ fontSize: 14, startupSnippetId: 9 }),
|
||||
}),
|
||||
).toEqual({
|
||||
quickActions: [
|
||||
{ name: "Restart", snippetId: 3 },
|
||||
{ name: "Str", snippetId: 4 },
|
||||
],
|
||||
startupSnippetId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads nothing from a row without either", () => {
|
||||
expect(
|
||||
snippetHostSettingsFromRow({
|
||||
quick_actions: "[]",
|
||||
terminal_config: JSON.stringify({ fontSize: 14 }),
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSnippetsSettingsMigration", () => {
|
||||
it("moves host and user values once", async () => {
|
||||
hostRows.push(
|
||||
{
|
||||
id: 1,
|
||||
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
|
||||
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
|
||||
},
|
||||
{ id: 2, quick_actions: null, terminal_config: "{}" },
|
||||
);
|
||||
preferenceRows.push(
|
||||
{ user_id: "u1", confirm_snippet_execution: 1, folders_collapsed: 0 },
|
||||
{
|
||||
user_id: "u2",
|
||||
confirm_snippet_execution: null,
|
||||
folders_collapsed: null,
|
||||
},
|
||||
);
|
||||
|
||||
const first = await runSnippetsSettingsMigration();
|
||||
expect(first).toEqual({ hostsMoved: 1, hostsSkipped: 0, usersMoved: 1 });
|
||||
expect(value("host", "1", "quickActions")).toEqual([
|
||||
{ name: "Up", snippetId: 2 },
|
||||
]);
|
||||
expect(value("host", "1", "startupSnippetId")).toBe(5);
|
||||
expect(value("user", "u1", "confirmExecution")).toBe(true);
|
||||
expect(value("user", "u1", "foldersCollapsed")).toBe(false);
|
||||
expect(find("user", "u2", "confirmExecution")).toBeUndefined();
|
||||
|
||||
const snapshot = JSON.stringify(pluginRows);
|
||||
const second = await runSnippetsSettingsMigration();
|
||||
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1, usersMoved: 0 });
|
||||
expect(JSON.stringify(pluginRows)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("never overwrites what the user already changed in the plugin", async () => {
|
||||
pluginRows.push({
|
||||
pluginId: "snippets",
|
||||
scope: "host",
|
||||
scopeId: "1",
|
||||
key: "startupSnippetId",
|
||||
value: "7",
|
||||
});
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
|
||||
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
|
||||
});
|
||||
await runSnippetsSettingsMigration();
|
||||
expect(value("host", "1", "startupSnippetId")).toBe(7);
|
||||
expect(find("host", "1", "quickActions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing while the plugin is not installed", async () => {
|
||||
pluginInstalled = false;
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
|
||||
terminal_config: null,
|
||||
});
|
||||
await runSnippetsSettingsMigration();
|
||||
expect(pluginRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* The terminal macros migration: a user's saved macros must come through
|
||||
* into the ssh-terminal plugin's macros setting, and running it twice must
|
||||
* change nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const pluginRows: Row[] = [];
|
||||
const preferenceRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
const find = (scopeId: string, key: string) =>
|
||||
pluginRows.find(
|
||||
(row) =>
|
||||
row.pluginId === "ssh-terminal" &&
|
||||
row.scope === "user" &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) => (id === "ssh-terminal" ? { id } : null),
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (_p: string, _s: string, scopeId: string, key: string) =>
|
||||
find(scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({ all: async () => preferenceRows }),
|
||||
}));
|
||||
|
||||
const { runSshTerminalMacrosMigration, macrosFromPreferences } =
|
||||
await import("../../upgrade/ssh-terminal-macros-migration.js");
|
||||
|
||||
const macro = { id: "m1", name: "Deploy", steps: [{ id: "s", type: "send" }] };
|
||||
|
||||
beforeEach(() => {
|
||||
pluginRows.length = 0;
|
||||
preferenceRows.length = 0;
|
||||
});
|
||||
|
||||
describe("macrosFromPreferences", () => {
|
||||
it("keeps valid macros and drops the rest", () => {
|
||||
expect(
|
||||
macrosFromPreferences(JSON.stringify([macro, { id: 1 }, "x"])),
|
||||
).toEqual([macro]);
|
||||
expect(macrosFromPreferences("[]")).toBeNull();
|
||||
expect(macrosFromPreferences("{bad")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSshTerminalMacrosMigration", () => {
|
||||
it("moves each user's macros once", async () => {
|
||||
preferenceRows.push(
|
||||
{ user_id: "u1", terminal_macros: JSON.stringify([macro]) },
|
||||
{ user_id: "u2", terminal_macros: "[]" },
|
||||
);
|
||||
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 1 });
|
||||
expect(JSON.parse(find("u1", "macros")!.value!)).toEqual([macro]);
|
||||
expect(find("u2", "macros")).toBeUndefined();
|
||||
|
||||
const snapshot = JSON.stringify(pluginRows);
|
||||
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 0 });
|
||||
expect(JSON.stringify(pluginRows)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("leaves a user who already has macros in the plugin alone", async () => {
|
||||
pluginRows.push({
|
||||
pluginId: "ssh-terminal",
|
||||
scope: "user",
|
||||
scopeId: "u1",
|
||||
key: "macros",
|
||||
value: "[]",
|
||||
});
|
||||
preferenceRows.push({
|
||||
user_id: "u1",
|
||||
terminal_macros: JSON.stringify([macro]),
|
||||
});
|
||||
await runSshTerminalMacrosMigration();
|
||||
expect(find("u1", "macros")!.value).toBe("[]");
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const { AuthManager, setPluginImpersonationCheck, allowsAdminImpersonation } =
|
||||
await import("../../utils/auth-manager.js");
|
||||
const authManager = AuthManager.getInstance();
|
||||
const middleware = authManager.createAuthMiddleware();
|
||||
|
||||
@@ -216,4 +217,48 @@ describe("AuthManager admin impersonation", () => {
|
||||
expect(nexted).toBe(true);
|
||||
expect((req as unknown as { userId: string }).userId).toBe("regular1");
|
||||
});
|
||||
|
||||
it("rejects the header on a plugin route whose manifest did not opt in", async () => {
|
||||
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
|
||||
try {
|
||||
const { res, nexted } = await runMiddleware(token("admin1"), {
|
||||
"x-admin-target-user": "target1",
|
||||
__url: "/plugin-api/other/items",
|
||||
});
|
||||
expect(nexted).toBe(false);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect((res.body as { code?: string }).code).toBe(
|
||||
"IMPERSONATION_NOT_ALLOWED",
|
||||
);
|
||||
} finally {
|
||||
setPluginImpersonationCheck(() => false);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an admin act for a user on a plugin that opted in", async () => {
|
||||
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
|
||||
try {
|
||||
const { req, nexted } = await runMiddleware(token("admin1"), {
|
||||
"x-admin-target-user": "target1",
|
||||
__url: "/plugin-api/opted-in/items?x=1",
|
||||
});
|
||||
expect(nexted).toBe(true);
|
||||
expect((req as unknown as { userId: string }).userId).toBe("target1");
|
||||
} finally {
|
||||
setPluginImpersonationCheck(() => false);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches the plugin id exactly, not as a prefix", () => {
|
||||
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
|
||||
try {
|
||||
expect(allowsAdminImpersonation("/plugin-api/opted-in")).toBe(true);
|
||||
expect(allowsAdminImpersonation("/plugin-api/opted-in-too/x")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(allowsAdminImpersonation("/users/sessions")).toBe(false);
|
||||
} finally {
|
||||
setPluginImpersonationCheck(() => false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user