feat: rebuild workspaces as the reference plugin

This commit is contained in:
LukeGus committed 2026-09-23 07:04:03 -05:00
1 parent d774df8b41
commit f217d8db89
79 files changed
+32995 -2184

No files matched your search

+42
View File
@@ -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);
});
});