mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: add the snippets plugin (B2)
This commit is contained in:
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user