mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: rebuild workspaces as the reference plugin
This commit is contained in:
1 parent
d774df8b41
commit
f217d8db89
79 files changed
+32995
-2184
No files matched your search
@@ -48,6 +48,15 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({ marker: "db-handle" }),
|
||||
}));
|
||||
|
||||
const saves: string[] = [];
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
forceSave: async (reason: string) => {
|
||||
saves.push(reason);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: { marker: "users" },
|
||||
hosts: { marker: "hosts" },
|
||||
@@ -90,6 +99,39 @@ beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
invalidatePluginPermissionCache();
|
||||
delete process.env.PLUGIN_MAX_KV_KEYS;
|
||||
delete process.env.DATABASE_DIALECT;
|
||||
saves.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.db.persist", () => {
|
||||
it("flushes SQLite to disk under the plugin's name", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual(["plugin_demo_write"]);
|
||||
expect(ctx.db.dialect).toBe("sqlite");
|
||||
});
|
||||
|
||||
it("does nothing on an engine that is already durable", async () => {
|
||||
process.env.DATABASE_DIALECT = "postgres";
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual([]);
|
||||
expect(ctx.db.dialect).toBe("postgres");
|
||||
});
|
||||
|
||||
it("refuses without db:own", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.db.persist()).rejects.toThrow(/db:own/);
|
||||
expect(saves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.db capability", () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The SDK's test helpers, against a fixture plugin in a temp directory.
|
||||
*
|
||||
* Plugin suites rely on createTestDb applying migrations exactly as the
|
||||
* server does and on createMockCtx enforcing permissions, so a regression in
|
||||
* either would pass every plugin's tests while proving nothing.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { adoptTableSql } from "@termix/plugin-sdk/ddl";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
|
||||
let root: string;
|
||||
let pluginDir: string;
|
||||
|
||||
const notes = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable("fleets", { id: id(), userId: refUser(), name: text() }),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sdk-testing-"));
|
||||
// The directory name is the plugin id, as it is in plugins/.
|
||||
pluginDir = path.join(root, "fleets");
|
||||
fs.mkdirSync(path.join(pluginDir, "migrations", "sqlite"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "migrations", "sqlite", "0001_adopt.sql"),
|
||||
adoptTableSql("sqlite", "fleets", notes).join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createTestDb", () => {
|
||||
it("applies the plugin's migrations after the before hook", async () => {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE fleets (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, name TEXT)",
|
||||
);
|
||||
sqlite.exec("INSERT INTO fleets (user_id, name) VALUES ('u1', 'kept')");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt"]);
|
||||
expect(db.sqlite.prepare("SELECT name FROM p_fleets_fleets").all()).toEqual(
|
||||
[{ name: "kept" }],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("hands back a database a plugin can query through define and client", async () => {
|
||||
const db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
|
||||
const table = (await db.database.define(notes)) as never;
|
||||
const drizzle = (await db.database.client()) as {
|
||||
insert: (t: never) => { values: (v: object) => Promise<unknown> };
|
||||
select: () => { from: (t: never) => Promise<unknown[]> };
|
||||
};
|
||||
await drizzle.insert(table).values({ userId: "u1", name: "n" });
|
||||
await db.database.persist();
|
||||
|
||||
expect(await drizzle.select().from(table)).toEqual([
|
||||
{ id: 1, userId: "u1", name: "n" },
|
||||
]);
|
||||
expect(db.persisted).toBe(1);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMockCtx permissions", () => {
|
||||
const respond = () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
it("passes everything when no permissions are given", async () => {
|
||||
const { ctx } = createMockCtx({ pluginId: "demo" });
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves short names and refuses what the user does not hold", async () => {
|
||||
const { ctx } = createMockCtx({
|
||||
pluginId: "demo",
|
||||
permissions: ["demo.use", "hosts.view"],
|
||||
});
|
||||
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
expect(await ctx.rbac.has("hosts.view")).toBe(true);
|
||||
expect(await ctx.rbac.has("manage")).toBe(false);
|
||||
|
||||
const res = respond();
|
||||
let passed = false;
|
||||
ctx.rbac.require("manage")({} as never, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
expect(passed).toBe(false);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toMatchObject({ required: "demo.manage" });
|
||||
});
|
||||
|
||||
it("uses the router factory and follows setActor", () => {
|
||||
const router = {};
|
||||
const mock = createMockCtx({
|
||||
pluginId: "demo",
|
||||
capabilities: ["network:serve"],
|
||||
router: () => router,
|
||||
});
|
||||
expect(mock.ctx.http.router()).toBe(router);
|
||||
|
||||
mock.setActor("u9");
|
||||
expect(mock.ctx.currentActor()).toBe("u9");
|
||||
});
|
||||
});
|
||||
@@ -166,6 +166,16 @@ describe("service registry", () => {
|
||||
expect(audits[0].success).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the provider as the user the permission was checked for", async () => {
|
||||
const { getActor } = await import("../../plugins/actor.js");
|
||||
register({
|
||||
implementation: { hello: async () => getActor() ?? "nobody" },
|
||||
});
|
||||
const { handle } = handleFor(true, "user-7");
|
||||
|
||||
await expect(handle.hello("x")).resolves.toBe("user-7");
|
||||
});
|
||||
|
||||
it("denies without the permission and never reaches the provider", async () => {
|
||||
const hello = vi.fn(async () => "should not run");
|
||||
register({ implementation: { hello } });
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
refHost,
|
||||
prefixedTableName,
|
||||
tablePrefix,
|
||||
adoptLegacyTable,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
import {
|
||||
adoptTableSql,
|
||||
createTableSql,
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
@@ -253,3 +256,56 @@ describe("buildTable", () => {
|
||||
expect(Object.keys(getTableColumns(table))).toContain("userId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adoptTableSql", () => {
|
||||
const adopted = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable(
|
||||
"fleets",
|
||||
{ id: id(), name: text().notNull().default("x") },
|
||||
{ indexes: [{ name: "idx_fleets_name", columns: ["id"] }] },
|
||||
),
|
||||
);
|
||||
|
||||
it("creates the legacy table when missing, indexes it, then renames it", () => {
|
||||
const sqlite = adoptTableSql("sqlite", "fleets", adopted);
|
||||
expect(sqlite[0]).toMatch(/^CREATE TABLE IF NOT EXISTS "fleets" \(/);
|
||||
expect(sqlite[1]).toBe(
|
||||
'CREATE INDEX IF NOT EXISTS "idx_fleets_name" ON "fleets" ("id");',
|
||||
);
|
||||
expect(sqlite[2]).toBe('ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";');
|
||||
|
||||
expect(adoptTableSql("postgres", "fleets", adopted).at(-1)).toBe(
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits no indexes on MySQL, which has no IF NOT EXISTS for them", () => {
|
||||
const mysql = adoptTableSql("mysql", "fleets", adopted);
|
||||
expect(mysql).toHaveLength(2);
|
||||
expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;");
|
||||
});
|
||||
|
||||
it("writes a TEXT default as an expression on MySQL", () => {
|
||||
expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain(
|
||||
"`name` text NOT NULL DEFAULT ('x')",
|
||||
);
|
||||
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
|
||||
"\"name\" text NOT NULL DEFAULT 'x'",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a definition that adopts nothing, and a table no plugin may adopt", () => {
|
||||
expect(() => adoptTableSql("sqlite", "demo", definition)).toThrow(
|
||||
/does not adopt/,
|
||||
);
|
||||
expect(() => adoptLegacyTable("users", definition)).toThrow(
|
||||
/not a legacy core table/,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits into statements the runner applies one at a time", () => {
|
||||
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
|
||||
expect(splitStatements(joined)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user