mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: finish the ai plugin (B18)
This commit is contained in:
1 parent
6b4d1432ab
commit
21937a6b02
193 files changed
+23429
-4244
No files matched your search
@@ -25,6 +25,8 @@ const h = vi.hoisted(() => ({
|
||||
statusReports: [] as Array<Record<string, unknown>>,
|
||||
statusPorts: [] as string[],
|
||||
statusPortsRemoved: [] as string[],
|
||||
userPermissions: new Set<string>(),
|
||||
deleted: [] as Array<{ userId: string; hostId: number }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -52,6 +54,8 @@ vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: async () => h.access,
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
h.userPermissions.has(permission),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -147,6 +151,13 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/delete-host.js", () => ({
|
||||
deleteOwnedHost: async (userId: string, hostId: number) => {
|
||||
h.deleted.push({ userId, hostId });
|
||||
return hostId === 404 ? null : { id: hostId, name: "gone" };
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
@@ -174,6 +185,8 @@ beforeEach(() => {
|
||||
h.updateResult = null;
|
||||
h.users = [];
|
||||
h.roles = [];
|
||||
h.userPermissions = new Set();
|
||||
h.deleted = [];
|
||||
});
|
||||
|
||||
describe("ctx.hosts", () => {
|
||||
@@ -402,6 +415,45 @@ describe("ctx.hosts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.delete", () => {
|
||||
it("refuses without hosts:write and audits it", async () => {
|
||||
const audit = vi.fn(async () => {});
|
||||
const hosts = createPluginHosts({ manifest: manifest([]), audit });
|
||||
await expect(hosts.delete(1)).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
"hosts_delete",
|
||||
"host 1",
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
expect(h.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an acting user without hosts.delete", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.delete(1)).rejects.toThrow("may not delete hosts");
|
||||
expect(h.deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes the actor's own host through core's delete path", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.userPermissions = new Set(["hosts.delete"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.delete(7)).toBe(true);
|
||||
expect(await hosts.delete(404)).toBe(false);
|
||||
expect(h.deleted).toEqual([
|
||||
{ userId: "user-1", hostId: 7 },
|
||||
{ userId: "user-1", hostId: 404 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts session tracking and activity", () => {
|
||||
it("counts a live session through core and releases it", () => {
|
||||
h.sessions = [];
|
||||
|
||||
@@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
|
||||
delivered: [] as Array<{ channelId: number; title: string }>,
|
||||
failChannel: null as number | null,
|
||||
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
|
||||
lastSignal: null as AbortSignal | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -58,9 +59,10 @@ vi.mock("../../utils/notification-sender.js", () => ({
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: async (
|
||||
url: string,
|
||||
_init: RequestInit,
|
||||
init: RequestInit,
|
||||
allowlist: readonly string[],
|
||||
) => {
|
||||
state.lastSignal = init.signal ?? null;
|
||||
state.fetchCalls.push({ url, allowlist });
|
||||
return new Response("ok", { status: 200 });
|
||||
},
|
||||
@@ -181,4 +183,15 @@ describe("ctx.fetch", () => {
|
||||
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
|
||||
const ctx = contextFor(["network:outbound"]);
|
||||
const controller = new AbortController();
|
||||
await ctx.fetch("https://example.com/stream", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(state.lastSignal?.aborted).toBe(false);
|
||||
controller.abort();
|
||||
expect(state.lastSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* ctx.secrets.get/set/delete: a plugin's own per-user secrets. Gated on
|
||||
* secrets:own, scoped to the acting user, encrypted at rest, and the value
|
||||
* never reaches the audit log.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: new Map<string, { value: string | null; encrypted: boolean }>(),
|
||||
}));
|
||||
|
||||
const rowKey = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => `${pluginId}|${scope}|${scopeId}|${key}`;
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => {
|
||||
const row = state.rows.get(rowKey(pluginId, scope, scopeId, key));
|
||||
return row ? { ...row } : null;
|
||||
},
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.rows.set(rowKey(pluginId, scope, scopeId, key), {
|
||||
value,
|
||||
encrypted,
|
||||
});
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => state.rows.delete(rowKey(pluginId, scope, scopeId, key)),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
|
||||
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { runAsActor } from "../../plugins/actor.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function contextFor(capabilities: string[]) {
|
||||
grants.set("demo", capabilities);
|
||||
const manifest = {
|
||||
id: "demo",
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
const handle = createPluginHandle("demo", { activate: () => {} });
|
||||
return createPluginContext(manifest, handle);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
state.rows.clear();
|
||||
});
|
||||
|
||||
describe("ctx.secrets store", () => {
|
||||
it("refuses every call without secrets:own", async () => {
|
||||
const ctx = contextFor([]);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
await expect(ctx.secrets.get("k")).rejects.toThrow(/secrets:own/);
|
||||
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/secrets:own/);
|
||||
await expect(ctx.secrets.delete("k")).rejects.toThrow(/secrets:own/);
|
||||
});
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_secret_delete",
|
||||
success: false,
|
||||
});
|
||||
expect(state.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a call with no acting user", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("stores encrypted, per user, and never audits the value", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
|
||||
await runAsActor("alice", "request", () =>
|
||||
ctx.secrets.set("provider:1", "sk-alice"),
|
||||
);
|
||||
|
||||
expect(state.rows.get("demo|secret|alice|provider:1")).toEqual({
|
||||
value: JSON.stringify("sysenc:sk-alice"),
|
||||
encrypted: true,
|
||||
});
|
||||
expect(
|
||||
await runAsActor("alice", "request", () => ctx.secrets.get("provider:1")),
|
||||
).toBe("sk-alice");
|
||||
expect(
|
||||
await runAsActor("bob", "request", () => ctx.secrets.get("provider:1")),
|
||||
).toBeNull();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_secret_set",
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.stringify(auditEntries)).not.toContain("sk-alice");
|
||||
});
|
||||
|
||||
it("clears on null and on delete", async () => {
|
||||
const ctx = contextFor(["secrets:own"]);
|
||||
await runAsActor("alice", "request", async () => {
|
||||
await ctx.secrets.set("a", "1");
|
||||
await ctx.secrets.set("b", "2");
|
||||
await ctx.secrets.set("a", null);
|
||||
await ctx.secrets.delete("b");
|
||||
expect(await ctx.secrets.get("a")).toBeNull();
|
||||
expect(await ctx.secrets.get("b")).toBeNull();
|
||||
});
|
||||
expect(state.rows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user