mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
332 lines
9.9 KiB
TypeScript
332 lines
9.9 KiB
TypeScript
/**
|
|
* 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<string, string[]>();
|
|
const auditEntries: Array<Record<string, unknown>> = [];
|
|
const kvStore = new Map<string, string>();
|
|
|
|
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<string, unknown>) => {
|
|
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<string, unknown>;
|
|
|
|
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");
|
|
});
|
|
});
|