feat: add the snippets plugin (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:59:16 -05:00
1 parent 1eac0f54b0
commit 3656caf168
29 files changed
+5293

No files matched your search

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("snippets activate", () => {
it("registers snippets and snippetFolders sync entities at the legacy order values", async () => {
server = await startServer();
const types = server.mock.syncEntities.map((e) => e.type);
expect(types).toContain("snippets");
expect(types).toContain("snippetFolders");
const snippetsEntity = server.mock.syncEntities.find(
(e) => e.type === "snippets",
);
const foldersEntity = server.mock.syncEntities.find(
(e) => e.type === "snippetFolders",
);
expect(snippetsEntity?.order).toBe(60);
expect(foldersEntity?.order).toBe(40);
});
it("offers the snippets.access service, scoped to the calling user", async () => {
server = await startServer();
await server.request("POST", "/", {
body: { name: "Deploy", content: "echo deploy" },
});
const service = server.mock.services.get("snippets.access") as {
list: () => Promise<unknown>;
};
const listed = await server.mock.ctx.asUser("user-1", () => service.list());
expect(listed).toEqual([
expect.objectContaining({ name: "Deploy", content: "echo deploy" }),
]);
const otherUsersList = await server.mock.ctx.asUser("user-2", () =>
service.list(),
);
expect(otherUsersList).toEqual([]);
});
it("wipes a user's snippets when user.data_wiped fires", async () => {
// events:core only to simulate core firing the topic in this test; the
// plugin's own manifest never declares it, since it only subscribes.
server = await startServer({
capabilities: [...manifest.capabilities, "events:core"],
});
await server.request("POST", "/", {
body: { name: "Deploy", content: "echo deploy" },
});
const before = (await server.request("GET", "/")).body;
expect(before).toHaveLength(1);
server.mock.ctx.events.emit("user.data_wiped", { userId: "user-1" });
// The listener's delete is fire-and-forget; give its microtasks a turn.
await new Promise((resolve) => setTimeout(resolve, 20));
const after = (await server.request("GET", "/")).body;
expect(after).toHaveLength(0);
});
it("fails closed without db:own", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: [
"network:serve",
"ssh:connect",
"credentials:use",
"ui:surface",
],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
it("fails closed without network:serve", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: ["db:own", "ssh:connect", "credentials:use", "ui:surface"],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
});
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import { EventEmitter } from "node:events";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { manifest, pluginDir } from "./helpers";
let db: TestDb | null = null;
afterEach(async () => {
db?.close();
db = null;
});
function fakeSshClient(output: string, exitCode: number | null = 0) {
const client = new EventEmitter() as EventEmitter & {
exec: (
command: string,
cb: (err: Error | null, stream: unknown) => void,
) => void;
};
client.exec = (command, cb) => {
const stream = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
};
stream.stderr = new EventEmitter();
cb(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from(output));
stream.emit("close", exitCode);
});
};
return client;
}
async function startExecuteServer(sshClient: unknown) {
db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES ('user-1', 'user-1')")
.run();
let router: import("express").Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
sshClient,
permissions: ["snippets.view", "snippets.create"],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const http = await import("node:http");
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as import("node:net").AddressInfo;
return {
async request(method: string, path: string, body?: unknown) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": "user-1",
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
describe("POST /execute", () => {
it("runs the resolved command over the ssh client and reports success", async () => {
const client = fakeSshClient("deployed ok", 0);
const server = await startExecuteServer(client);
try {
const created = await server.request("POST", "/", {
name: "Deploy",
content: "echo $HOST",
});
const result = await server.request("POST", "/execute", {
snippetId: created.body.id,
hostId: 42,
});
expect(result.status).toBe(200);
expect(result.body).toEqual({ success: true, output: "deployed ok" });
} finally {
await server.close();
}
});
it("reports failure from a nonzero exit code", async () => {
const client = fakeSshClient("", 1);
const server = await startExecuteServer(client);
try {
const created = await server.request("POST", "/", {
name: "Fails",
content: "exit 1",
});
const result = await server.request("POST", "/execute", {
snippetId: created.body.id,
hostId: 42,
});
expect(result.status).toBe(200);
expect(result.body.success).toBe(false);
} finally {
await server.close();
}
});
});
+94
View File
@@ -0,0 +1,94 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
permissions?: string[];
users?: string[];
capabilities?: string[];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? [
"snippets.view",
"snippets.create",
"snippets.edit",
"snippets.delete",
"snippets.share",
],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// snippets / snippet_folders / snippet_access as core's schema.ts declared
// them before 2.9.0, plus the indexes performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
folder TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
sync_id TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
host_filter TEXT,
is_note INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_snippets_user_id ON snippets (user_id);
CREATE TABLE snippet_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
sync_id TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE snippet_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
granted_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission_level TEXT NOT NULL DEFAULT 'view',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_snippet_access_user_id ON snippet_access (user_id);
CREATE INDEX idx_snippet_access_snippet_id ON snippet_access (snippet_id);
CREATE INDEX idx_snippet_access_role_id ON snippet_access (role_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting snippets, snippet_folders and snippet_access", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(
"INSERT INTO roles (id, name, display_name) VALUES (7, 'ops', 'Operations')",
);
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO snippets (id, user_id, name, content, folder, sync_id) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(99, "user-1", "Deploy", "echo deploy", "prod", "sync-snippet-a");
sqlite
.prepare(
"INSERT INTO snippet_folders (user_id, name, sync_id) VALUES (?, ?, ?)",
)
.run("user-1", "prod", "sync-folder-a");
sqlite
.prepare(
"INSERT INTO snippet_access (snippet_id, user_id, granted_by) VALUES (?, ?, ?)",
)
.run(99, "user-2", "user-1");
},
});
expect(db.applied).toEqual(["0001_adopt_snippets_tables"]);
expect(tableExists("snippets")).toBe(false);
expect(tableExists("snippet_folders")).toBe(false);
expect(tableExists("snippet_access")).toBe(false);
expect(tableExists("p_snippets_snippets")).toBe(true);
expect(tableExists("p_snippets_snippet_folders")).toBe(true);
expect(tableExists("p_snippets_snippet_access")).toBe(true);
const snippetRows = db.sqlite
.prepare(
"SELECT user_id, name, content, folder, sync_id FROM p_snippets_snippets",
)
.all();
expect(snippetRows).toEqual([
{
user_id: "user-1",
name: "Deploy",
content: "echo deploy",
folder: "prod",
sync_id: "sync-snippet-a",
},
]);
const folderRows = db.sqlite
.prepare("SELECT user_id, name, sync_id FROM p_snippets_snippet_folders")
.all();
expect(folderRows).toEqual([
{ user_id: "user-1", name: "prod", sync_id: "sync-folder-a" },
]);
const accessRows = db.sqlite
.prepare(
"SELECT snippet_id, user_id, granted_by FROM p_snippets_snippet_access",
)
.all();
expect(accessRows).toEqual([
{ snippet_id: 99, user_id: "user-2", granted_by: "user-1" },
]);
expect(indexOn("p_snippets_snippets")).toEqual(["idx_snippets_user_id"]);
expect(indexOn("p_snippets_snippet_access").sort()).toEqual(
[
"idx_snippet_access_user_id",
"idx_snippet_access_snippet_id",
"idx_snippet_access_role_id",
].sort(),
);
});
it("creates the tables fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("snippets")).toBe(false);
expect(tableExists("p_snippets_snippets")).toBe(true);
expect(tableExists("p_snippets_snippet_folders")).toBe(true);
expect(tableExists("p_snippets_snippet_access")).toBe(true);
expect(indexOn("p_snippets_snippets")).toEqual(["idx_snippets_user_id"]);
expect(
db.sqlite.prepare("SELECT * FROM p_snippets_snippets").all(),
).toEqual([]);
});
it("cascades deletes when the owning user is removed", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
db.sqlite
.prepare(
"INSERT INTO p_snippets_snippets (user_id, name, content) VALUES (?, ?, ?)",
)
.run("user-1", "Mine", "echo mine");
db.sqlite
.prepare(
"INSERT INTO p_snippets_snippets (user_id, name, content) VALUES (?, ?, ?)",
)
.run("user-2", "Theirs", "echo theirs");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
const names = (
db.sqlite.prepare("SELECT name FROM p_snippets_snippets").all() as {
name: string;
}[]
).map((row) => row.name);
expect(names).toEqual(["Theirs"]);
});
});
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import {
snippetAccess,
snippetFolders,
snippets,
} from "../../src/backend/tables.js";
import {
createSnippetRepository,
type SnippetRepository,
} from "../../src/backend/repository.js";
import { pluginDir } from "./helpers";
let db: TestDb;
let repo: SnippetRepository;
beforeEach(async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(
"INSERT INTO roles (id, name, display_name) VALUES (7, 'ops', 'Operations')",
);
},
});
repo = createSnippetRepository(
db.database,
await db.database.define(snippets),
await db.database.define(snippetFolders),
await db.database.define(snippetAccess),
);
});
afterEach(() => db.close());
describe("snippet repository", () => {
it("reads a new row back by its own sync id", async () => {
const a = await repo.createSnippet("user-1", {
name: "A",
content: "echo a",
});
const b = await repo.createSnippet("user-1", {
name: "B",
content: "echo b",
});
expect(a.name).toBe("A");
expect(b.name).toBe("B");
expect(a.syncId).not.toBe(b.syncId);
});
it("auto-assigns the next order within a folder", async () => {
await repo.createSnippet("user-1", {
name: "A",
content: "a",
folder: "prod",
});
const second = await repo.createSnippet("user-1", {
name: "B",
content: "b",
folder: "prod",
});
const rootLevel = await repo.createSnippet("user-1", {
name: "C",
content: "c",
});
expect(second.order).toBe(1);
expect(rootLevel.order).toBe(0);
});
it("renames a folder and re-points its snippets, refusing a name collision", async () => {
await repo.createFolder("user-1", "prod", null, null);
await repo.createFolder("user-1", "staging", null, null);
const snippet = await repo.createSnippet("user-1", {
name: "A",
content: "a",
folder: "prod",
});
const conflict = await repo.renameFolder("user-1", "prod", "staging");
expect(conflict.status).toBe("conflict");
const missing = await repo.renameFolder("user-1", "ghost", "new");
expect(missing.status).toBe("missing");
const renamed = await repo.renameFolder("user-1", "prod", "production");
expect(renamed.status).toBe("renamed");
const updated = await repo.findOwnedById("user-1", snippet.id);
expect(updated?.folder).toBe("production");
});
it("moves snippets to the root when their folder is deleted", async () => {
await repo.createFolder("user-1", "prod", null, null);
const snippet = await repo.createSnippet("user-1", {
name: "A",
content: "a",
folder: "prod",
});
const result = await repo.deleteFolder("user-1", "prod");
expect(result).not.toBeNull();
const updated = await repo.findOwnedById("user-1", snippet.id);
expect(updated?.folder).toBeNull();
});
it("bulk imports, skipping existing snippets unless overwrite is set", async () => {
await repo.createSnippet("user-1", { name: "A", content: "old" });
const skipped = await repo.bulkImport(
"user-1",
[{ name: "A", content: "new" }],
[],
false,
);
expect(skipped.snippetsSkipped).toBe(1);
expect(skipped.snippetsImported).toBe(0);
const overwritten = await repo.bulkImport(
"user-1",
[{ name: "A", content: "new" }],
[],
true,
);
expect(overwritten.snippetsUpdated).toBe(1);
const list = await repo.listOwnedSnippets("user-1");
expect(list).toHaveLength(1);
expect(list[0].content).toBe("new");
});
it("shares a snippet and lists it for the recipient, then revokes it", async () => {
const snippet = await repo.createSnippet("user-1", {
name: "A",
content: "a",
});
const created = await repo.upsertSnippetAccess({
snippetId: snippet.id,
targetType: "user",
targetUserId: "user-2",
grantedBy: "user-1",
expiresAt: null,
});
expect(created.created).toBe(true);
const again = await repo.upsertSnippetAccess({
snippetId: snippet.id,
targetType: "user",
targetUserId: "user-2",
grantedBy: "user-1",
expiresAt: "2099-01-01T00:00:00.000Z",
});
expect(again.created).toBe(false);
expect(again.id).toBe(created.id);
const roleIds = await repo.listUserRoleIds("user-2");
const shared = await repo.listSharedSnippets("user-2", roleIds);
expect(shared).toEqual([
expect.objectContaining({ id: snippet.id, name: "A" }),
]);
const accessible = await repo.findAccessibleSharedSnippet(
snippet.id,
"user-2",
roleIds,
);
expect(accessible?.id).toBe(snippet.id);
await repo.revokeSnippetAccess(created.id, snippet.id);
const afterRevoke = await repo.listSharedSnippets("user-2", roleIds);
expect(afterRevoke).toEqual([]);
});
it("wipes a user's snippets and folders on deleteByUserId", async () => {
await repo.createSnippet("user-1", { name: "A", content: "a" });
await repo.createFolder("user-1", "prod", null, null);
await repo.createSnippet("user-2", { name: "B", content: "b" });
await repo.deleteByUserId("user-1");
expect(await repo.listOwnedSnippets("user-1")).toEqual([]);
expect(await repo.listFolders("user-1")).toEqual([]);
expect(await repo.listOwnedSnippets("user-2")).toHaveLength(1);
});
});
@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer;
beforeEach(async () => {
server = await startServer();
});
afterEach(async () => {
await server.close();
});
async function create(
overrides: Record<string, unknown> = {},
user = "user-1",
) {
const res = await server.request("POST", "/", {
user,
body: { name: "Deploy", content: "echo deploy", ...overrides },
});
expect(res.status).toBe(201);
return res.body;
}
describe("snippet routes", () => {
it("creates and lists snippets, tagging owned ones as not shared", async () => {
const created = await create();
expect(created).toMatchObject({ name: "Deploy", content: "echo deploy" });
expect(created.syncId).toEqual(expect.any(String));
const list = await server.request("GET", "/");
expect(list.body).toEqual([
expect.objectContaining({ id: created.id, isShared: false }),
]);
});
it("rejects a missing name or content", async () => {
expect(
(await server.request("POST", "/", { body: { content: "x" } })).status,
).toBe(400);
expect(
(await server.request("POST", "/", { body: { name: "x" } })).status,
).toBe(400);
});
it("gets, updates and deletes a snippet", async () => {
const created = await create();
const got = await server.request("GET", `/${created.id}`);
expect(got.status).toBe(200);
expect(got.body.id).toBe(created.id);
const updated = await server.request("PUT", `/${created.id}`, {
body: { content: "echo updated" },
});
expect(updated.status).toBe(200);
expect(updated.body.content).toBe("echo updated");
const deleted = await server.request("DELETE", `/${created.id}`);
expect(deleted.status).toBe(200);
expect((await server.request("GET", `/${created.id}`)).status).toBe(404);
});
it("keeps snippets isolated per user", async () => {
await create({ name: "Owner snippet" }, "user-1");
const list = await server.request("GET", "/", { user: "user-2" });
expect(list.body).toEqual([]);
});
it("creates, renames and deletes a folder, moving its snippets to the root", async () => {
const folder = await server.request("POST", "/folders", {
body: { name: "prod", color: "#fff", icon: "server" },
});
expect(folder.status).toBe(201);
const conflict = await server.request("POST", "/folders", {
body: { name: "prod" },
});
expect(conflict.status).toBe(409);
const rename = await server.request("PUT", "/folders/rename", {
body: { oldName: "prod", newName: "production" },
});
expect(rename.status).toBe(200);
const snippet = await create({ folder: "production" });
expect(snippet.folder).toBe("production");
const deleteFolder = await server.request("DELETE", "/folders/production");
expect(deleteFolder.status).toBe(200);
const afterDelete = await server.request("GET", `/${snippet.id}`);
expect(afterDelete.body.folder).toBeNull();
});
it("reorders snippets, accepting both the snippets and legacy updates keys", async () => {
const a = await create({ name: "a" });
const b = await create({ name: "b" });
const reordered = await server.request("PUT", "/reorder", {
body: {
snippets: [
{ id: a.id, order: 2 },
{ id: b.id, order: 1 },
],
},
});
expect(reordered.status).toBe(200);
const legacy = await server.request("PUT", "/reorder", {
body: { updates: [{ id: a.id, order: 5 }] },
});
expect(legacy.status).toBe(200);
expect((await server.request("PUT", "/reorder", { body: {} })).status).toBe(
400,
);
});
it("exports and bulk-imports snippets and folders", async () => {
await create({ name: "Exportable" });
const exported = await server.request("GET", "/export");
expect(exported.status).toBe(200);
expect(exported.body.snippets).toHaveLength(1);
const imported = await server.request("POST", "/bulk-import", {
body: {
snippets: [{ name: "Imported", content: "echo hi" }],
folders: [],
overwrite: false,
},
});
expect(imported.status).toBe(200);
expect(imported.body.snippetsImported).toBe(1);
});
it("shares a snippet with another user, who can then read it as shared", async () => {
const snippet = await create();
const share = await server.request("POST", `/${snippet.id}/share`, {
body: { targetType: "user", targetUserId: "user-2" },
});
expect(share.status).toBe(200);
const sharedList = await server.request("GET", "/shared", {
user: "user-2",
});
expect(sharedList.body.sharedSnippets).toEqual([
expect.objectContaining({ id: snippet.id, name: snippet.name }),
]);
const visibleToOwner = await server.request("GET", `/${snippet.id}/access`);
expect(visibleToOwner.status).toBe(200);
expect(visibleToOwner.body).toHaveLength(1);
const revoke = await server.request(
"DELETE",
`/${snippet.id}/access/${visibleToOwner.body[0].id}`,
);
expect(revoke.status).toBe(200);
const afterRevoke = await server.request("GET", "/shared", {
user: "user-2",
});
expect(afterRevoke.body.sharedSnippets).toEqual([]);
});
it("refuses sharing a snippet the caller does not own", async () => {
const snippet = await create({}, "user-1");
const attempt = await server.request("POST", `/${snippet.id}/share`, {
user: "user-2",
body: { targetType: "user", targetUserId: "user-1" },
});
expect(attempt.status).toBe(403);
});
it("shares every snippet in a folder at once", async () => {
await create({ name: "s1", folder: "team" });
await create({ name: "s2", folder: "team" });
const share = await server.request("PUT", "/folder/share", {
body: { folder: "team", targetType: "user", targetUserId: "user-2" },
});
expect(share.status).toBe(200);
expect(share.body.snippetsShared).toBe(2);
});
it("denies every route without its permission", async () => {
const denied = await startServer({ permissions: [] });
try {
expect((await denied.request("GET", "/")).status).toBe(403);
expect((await denied.request("POST", "/", { body: {} })).status).toBe(
403,
);
expect((await denied.request("GET", "/folders")).status).toBe(403);
expect(
(await denied.request("POST", "/folders", { body: { name: "x" } }))
.status,
).toBe(403);
} finally {
await denied.close();
}
});
it("lets execute run without any snippets.* permission", async () => {
const noPerms = await startServer({ permissions: [] });
try {
const res = await noPerms.request("POST", "/execute", {
body: { snippetId: 999, hostId: 1 },
});
// 404 (no accessible snippet), not 403: execute needs no grant.
expect(res.status).toBe(404);
} finally {
await noPerms.close();
}
});
it("rejects executing a note", async () => {
const note = await server.request("POST", "/", {
body: { name: "Note", content: "remember this", isNote: true },
});
const res = await server.request("POST", "/execute", {
body: { snippetId: note.body.id, hostId: 1 },
});
expect(res.status).toBe(400);
});
it("404s executing a snippet the caller cannot see", async () => {
const res = await server.request("POST", "/execute", {
body: { snippetId: 999, hostId: 1 },
});
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const panels = declared(contributes.panels);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
});
it("registers the rail item gated on the view permission", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const railItem = rendered.registered
.railItems()
.find((i) => i.id === "snippets");
expect(railItem).toBeDefined();
expect(railItem?.permission).toBe("snippets.view");
});
it("registers the snippets.resolveForTerminal action", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions()).toContain(
"snippets.resolveForTerminal",
);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
});
describe("snippets panel", () => {
it("renders the empty state when the user has no snippets", async () => {
const api = {
get: vi.fn(async () => ({ data: [] })),
};
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as never,
permissions: ["snippets.view", "snippets.create"],
});
rendered.renderPanel("snippets", { active: true });
await expect(screen.findByPlaceholderText(/search/i)).resolves.toBeTruthy();
});
it("renders nothing without the view permission", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
permissions: [],
});
const panel = rendered.renderPanel("snippets", { active: true });
expect(panel.textContent).toBe("");
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
extractSnippetInputs,
hasSnippetInputs,
resolveSnippetContent,
} from "../../src/frontend/snippet-variables";
describe("extractSnippetInputs", () => {
it("extracts a single unlabeled input", () => {
expect(extractSnippetInputs("ping $INPUT_1")).toEqual([
{ key: "INPUT_1", label: "Input 1" },
]);
});
it("extracts a labeled input using brace syntax", () => {
expect(
extractSnippetInputs("nc -zv $HOST ${INPUT_1:Port to check}"),
).toEqual([{ key: "INPUT_1", label: "Port to check" }]);
});
it("plain (non-braced) references have no label", () => {
expect(extractSnippetInputs("echo $INPUT_2")).toEqual([
{ key: "INPUT_2", label: "Input 2" },
]);
});
it("de-duplicates repeated references and preserves first-seen order", () => {
expect(
extractSnippetInputs("$INPUT_2 then $INPUT_1 then $INPUT_2 again"),
).toEqual([
{ key: "INPUT_2", label: "Input 2" },
{ key: "INPUT_1", label: "Input 1" },
]);
});
it("returns an empty array when there are no placeholders", () => {
expect(extractSnippetInputs("uptime")).toEqual([]);
});
});
describe("hasSnippetInputs", () => {
it("returns true when a snippet has an input placeholder", () => {
expect(hasSnippetInputs("echo $INPUT_1")).toBe(true);
});
it("returns false when a snippet has no input placeholder", () => {
expect(hasSnippetInputs("echo $HOST")).toBe(false);
});
});
describe("resolveSnippetContent", () => {
const host = { ip: "10.0.0.5", username: "root", port: 22, name: "web-01" };
it("substitutes host variables", () => {
expect(
resolveSnippetContent("ssh $USER@$HOST -p $PORT # $NAME", host),
).toBe("ssh root@10.0.0.5 -p 22 # web-01");
});
it("supports brace syntax for host variables", () => {
expect(resolveSnippetContent("ping ${HOST}", host)).toBe("ping 10.0.0.5");
});
it("substitutes input placeholders", () => {
expect(
resolveSnippetContent("nc -zv $HOST ${INPUT_1:Port}", host, {
INPUT_1: "8080",
}),
).toBe("nc -zv 10.0.0.5 8080");
});
it("leaves host variables literal when no host context is given", () => {
expect(resolveSnippetContent("ping $HOST", null)).toBe("ping $HOST");
});
it("leaves input placeholders literal when no value was supplied", () => {
expect(resolveSnippetContent("echo $INPUT_1", null)).toBe("echo $INPUT_1");
});
});