/** * ctx.db and the kv caps. * * db:own was in the catalog from A1 but nothing checked it. These assert that * it is checked where the privileged thing happens, and audited either way. */ import { beforeEach, describe, expect, it, vi } from "vitest"; const grants = new Map(); const auditEntries: Array> = []; const kvStore = new Map(); vi.mock("../../database/repositories/factory.js", () => ({ createCurrentPluginPermissionGrantRepository: () => ({ listByPlugin: async (pluginId: string) => (grants.get(pluginId) ?? []).map((capability) => ({ pluginId, capability, })), }), createCurrentPluginStorageRepository: () => ({ get: async (pluginId: string, key: string) => kvStore.get(`${pluginId}:${key}`) ?? null, set: async (pluginId: string, key: string, value: string) => { kvStore.set(`${pluginId}:${key}`, value); }, delete: async (pluginId: string, key: string) => kvStore.delete(`${pluginId}:${key}`), listKeys: async (pluginId: string) => [...kvStore.keys()] .filter((key) => key.startsWith(`${pluginId}:`)) .map((key) => key.slice(pluginId.length + 1)), countKeys: async (pluginId: string) => [...kvStore.keys()].filter((key) => key.startsWith(`${pluginId}:`)) .length, }), })); vi.mock("../../utils/audit-logger.js", () => ({ logAudit: async (entry: Record) => { auditEntries.push(entry); }, })); 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" }, roles: { marker: "roles" }, userRoles: { marker: "userRoles" }, })); import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { resetPluginData } from "../../plugins/data.js"; import { resetSyncRegistry, listEntityTypes, } from "../../plugins/sync-registry.js"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; function manifestFor(pluginId: string, capabilities: string[]): PluginManifest { return { id: pluginId, name: pluginId, version: "1.0.0", description: "", author: { name: "test" }, license: "MIT", category: "Productivity", engine: { termix: ">=2.9.0", api: "1" }, capabilities, } as PluginManifest; } function contextFor(pluginId: string, capabilities: string[]) { const manifest = manifestFor(pluginId, capabilities); const handle = createPluginHandle(pluginId, { activate: () => {} }); return { ctx: createPluginContext(manifest, handle), handle }; } beforeEach(() => { grants.clear(); auditEntries.length = 0; kvStore.clear(); resetPluginData(); 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", () => { it("refuses define without db:own", async () => { grants.set("demo", []); const { ctx } = contextFor("demo", []); await expect( ctx.db.define({ name: "thing", columns: {}, indexes: [] }), ).rejects.toThrow(/db:own/); }); it("refuses define when granted but not declared", async () => { // A grant for something the manifest never declared is ignored, so // widening a plugin's reach always needs a manifest the user can see. grants.set("demo", ["db:own"]); const { ctx } = contextFor("demo", []); await expect( ctx.db.define({ name: "thing", columns: {}, indexes: [] }), ).rejects.toThrow(/db:own/); }); it("refuses the client handle without db:own", async () => { grants.set("demo", []); const { ctx } = contextFor("demo", []); await expect(ctx.db.client()).rejects.toThrow(/db:own/); }); it("allows define when declared and granted", async () => { grants.set("demo", ["db:own"]); const { ctx } = contextFor("demo", ["db:own"]); const table = await ctx.db.define({ name: "thing", columns: { id: { type: "id", primaryKey: true, notNull: true } }, indexes: [], }); expect(table).toBeTruthy(); }); it("audits a refused call as well as an allowed one", async () => { grants.set("demo", []); const { ctx } = contextFor("demo", []); await expect(ctx.db.client()).rejects.toThrow(); const entry = auditEntries.find((row) => row.action === "plugin_db_client"); expect(entry).toMatchObject({ success: false, username: "plugin:demo", resourceId: "demo", }); }); it("refuses core refs with only db:own", async () => { grants.set("demo", ["db:own"]); const { ctx } = contextFor("demo", ["db:own"]); await expect(ctx.db.refs()).rejects.toThrow(/db:core-refs/); }); it("hands back refs to the core tables a plugin may join", async () => { grants.set("demo", ["db:core-refs"]); const { ctx } = contextFor("demo", ["db:core-refs"]); const refs = (await ctx.db.refs()) as Record; expect(Object.keys(refs).sort()).toEqual([ "hosts", "roles", "userRoles", "users", ]); }); }); // Every audit row rewrote the whole SQLite file, and pollers fetch the // client before every query (2.9.1 wrote 100 GB in hours). describe("ctx audit volume", () => { it("audits the client handle once however often it is fetched", async () => { grants.set("demo", ["db:own"]); const { ctx } = contextFor("demo", ["db:own"]); for (let i = 0; i < 100; i++) await ctx.db.client(); expect( auditEntries.filter((row) => row.action === "plugin_db_client"), ).toHaveLength(1); }); it("audits kv reads once but every kv write", async () => { grants.set("demo", ["kv:own"]); const { ctx } = contextFor("demo", ["kv:own"]); for (let i = 0; i < 5; i++) await ctx.kv.get("k"); for (let i = 0; i < 3; i++) await ctx.kv.set("k", i); const actions = auditEntries.map((row) => row.action); expect(actions.filter((a) => a === "plugin_kv_get")).toHaveLength(1); expect(actions.filter((a) => a === "plugin_kv_set")).toHaveLength(3); }); it("still audits a refusal after the first success", async () => { grants.set("demo", ["db:own"]); const { ctx } = contextFor("demo", ["db:own"]); await ctx.db.client(); grants.set("demo", []); invalidatePluginPermissionCache("demo"); await expect(ctx.db.client()).rejects.toThrow(); const entries = auditEntries.filter( (row) => row.action === "plugin_db_client", ); expect(entries.map((row) => row.success)).toEqual([true, false]); }); }); describe("ctx.kv limits", () => { it("refuses a key over the length cap", async () => { grants.set("demo", ["kv:own"]); const { ctx } = contextFor("demo", ["kv:own"]); await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow( /at most 128 characters/, ); }); it("refuses a value over the size cap", async () => { grants.set("demo", ["kv:own"]); const { ctx } = contextFor("demo", ["kv:own"]); await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow( /byte limit/, ); }); it("refuses a new key past the key-count cap", async () => { process.env.PLUGIN_MAX_KV_KEYS = "2"; grants.set("demo", ["kv:own"]); const { ctx } = contextFor("demo", ["kv:own"]); await ctx.kv.set("a", 1); await ctx.kv.set("b", 2); await expect(ctx.kv.set("c", 3)).rejects.toThrow(/key limit/); }); it("still allows overwriting an existing key when full", async () => { // Only a new key grows the table, so a full store is not read-only. process.env.PLUGIN_MAX_KV_KEYS = "2"; grants.set("demo", ["kv:own"]); const { ctx } = contextFor("demo", ["kv:own"]); await ctx.kv.set("a", 1); await ctx.kv.set("b", 2); await expect(ctx.kv.set("a", 99)).resolves.toBeUndefined(); expect(await ctx.kv.get("a")).toBe(99); }); it("counts each plugin's keys separately", async () => { process.env.PLUGIN_MAX_KV_KEYS = "1"; grants.set("one", ["kv:own"]); grants.set("two", ["kv:own"]); await contextFor("one", ["kv:own"]).ctx.kv.set("a", 1); await expect( contextFor("two", ["kv:own"]).ctx.kv.set("a", 1), ).resolves.toBeUndefined(); }); }); describe("ctx.sync", () => { it("registers an entity a plugin owns", () => { grants.set("demo", []); const { ctx } = contextFor("demo", []); ctx.sync.registerEntity({ type: "demoThings", table: {}, order: 200 }); expect(listEntityTypes()).toContain("demoThings"); }); it("drops the registration when the plugin is disposed", async () => { grants.set("demo", []); const { ctx, handle } = contextFor("demo", []); ctx.sync.registerEntity({ type: "demoThings", table: {} }); await handle.bag.disposeAll(); expect(listEntityTypes()).not.toContain("demoThings"); }); });