Files
Termix/src/backend/tests/plugins/ctx-notify.test.ts
T

325 lines
10 KiB
TypeScript

/**
* ctx.notify and ctx.fetch: each is gated on its capability and audited.
* notify resolves the audience in core and hands the hub plain user ids, and
* fetch goes through the SSRF guard with only the hosts the caller listed.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
PluginNotification,
PluginNotifyHub,
} from "@termix-ssh/plugin-sdk/backend";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
users: [
{ id: "alice", isAdmin: true },
{ id: "bob", isAdmin: false },
{ id: "carol", isAdmin: false },
],
permissions: new Map<string, string[]>([
["bob", ["docker.view"]],
["carol", ["docker.view"]],
]),
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
lastTls: null as Record<string, unknown> | null,
lastIdleTimeout: undefined as number | undefined,
lastSignal: null as AbortSignal | null,
lastRedirect: undefined as RequestRedirect | undefined,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentUserRepository: () => ({
listAll: async () => state.users,
findById: async (id: string) =>
state.users.find((user) => user.id === id) ?? null,
}),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (userId: string, permission: string) =>
state.permissions.get(userId)?.includes(permission) ?? false,
}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: async (
url: string,
init: RequestInit,
allowlist: readonly string[],
tls: Record<string, unknown> = {},
idleTimeoutMs?: number,
) => {
state.lastTls = tls;
state.lastIdleTimeout = idleTimeoutMs;
state.lastRedirect = init.redirect;
state.lastSignal = init.signal ?? null;
state.fetchCalls.push({ url, allowlist });
return new Response("ok", { status: 200 });
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import { clearNotifyHub } from "../../plugins/notify-hub.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
function contextFor(
capabilities: string[],
id = "demo",
granted: string[] = capabilities,
) {
grants.set(id, granted);
const manifest = {
id,
name: id,
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(id, { activate: () => {} });
return createPluginContext(manifest, handle);
}
function fakeHub() {
const calls: Array<{
source: string;
recipients: string[];
notification: PluginNotification;
}> = [];
const hub: PluginNotifyHub = {
deliver: async (input) => {
calls.push(input);
return {
recipients: input.recipients.length,
delivered: 0,
failures: [],
};
},
channels: async (userId) => [
{ id: 1, name: `channel of ${userId}`, type: "webhook", enabled: true },
],
};
return { hub, calls };
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
clearNotifyHub();
state.fetchCalls.length = 0;
});
describe("ctx.notify", () => {
it("refuses without notify:send and audits the refusal", async () => {
const ctx = contextFor([]);
await expect(
runAsActor("alice", "request", () => ctx.notify.channels()),
).rejects.toThrow(/notify:send/);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_notify_channels",
success: false,
});
});
it("refuses a send with neither an acting user nor an audience", async () => {
const ctx = contextFor(["notify:send"]);
await expect(ctx.notify.send({ title: "t" })).rejects.toThrow(
/acting user/,
);
});
it("reaches nobody while no hub is running", async () => {
const ctx = contextFor(["notify:send"]);
const result = await runAsActor("alice", "request", () =>
ctx.notify.send({ title: "Disk" }),
);
expect(result).toEqual({ recipients: 0, delivered: 0, failures: [] });
expect(
await runAsActor("alice", "request", () => ctx.notify.channels()),
).toEqual([]);
});
it("hands the hub the acting user and the sending plugin by default", async () => {
const { hub, calls } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
const result = await runAsActor("alice", "request", () =>
ctx.notify.send({ title: "Disk", severity: "critical" }),
);
expect(result.recipients).toBe(1);
expect(calls).toEqual([
{
source: "demo",
recipients: ["alice"],
notification: { title: "Disk", severity: "critical" },
},
]);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_notify_send",
success: true,
});
});
it("resolves a named user, admins and a permission in core", async () => {
const { hub, calls } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
await ctx.notify.send({ title: "a", audience: { userId: "bob" } });
await ctx.notify.send({ title: "b", audience: "admins" });
await ctx.notify.send({
title: "c",
audience: { permission: "docker.view" },
});
expect(calls.map((call) => call.recipients)).toEqual([
["bob"],
["alice"],
["bob", "carol"],
]);
});
it("does not call the hub for an audience that names nobody", async () => {
const { hub, calls } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
const result = await ctx.notify.send({
title: "x",
audience: { userId: "ghost" },
});
expect(result.recipients).toBe(0);
expect(calls).toEqual([]);
});
it("asks the hub for the acting user's channels", async () => {
const { hub } = fakeHub();
contextFor(["notify:hub"], "hub").notify.serve(hub);
const ctx = contextFor(["notify:send"]);
expect(
await runAsActor("bob", "request", () => ctx.notify.channels()),
).toEqual([
{ id: 1, name: "channel of bob", type: "webhook", enabled: true },
]);
});
it("only serves a hub the manifest declares notify:hub for", () => {
const { hub } = fakeHub();
expect(() =>
contextFor(["notify:send"], "rogue").notify.serve(hub),
).toThrow(/notify:hub/);
});
it("refuses a second hub from another plugin", () => {
contextFor(["notify:hub"], "hub").notify.serve(fakeHub().hub);
expect(() =>
contextFor(["notify:hub"], "other").notify.serve(fakeHub().hub),
).toThrow(/already/);
});
it("stops using a hub once it is revoked or its grant is gone", async () => {
const first = fakeHub();
const revoke = contextFor(["notify:hub"], "hub").notify.serve(first.hub);
const ctx = contextFor(["notify:send"]);
revoke();
await ctx.notify.send({ title: "x", audience: "admins" });
expect(first.calls).toEqual([]);
const second = fakeHub();
contextFor(["notify:hub"], "ungranted", []).notify.serve(second.hub);
await ctx.notify.send({ title: "y", audience: "admins" });
expect(second.calls).toEqual([]);
});
});
describe("ctx.fetch", () => {
it("forwards manual redirect handling without enabling redirect following", async () => {
const ctx = contextFor(["network:outbound"]);
await ctx.fetch("https://example.com", { redirect: "manual" });
expect(state.lastRedirect).toBe("manual");
await ctx.fetch("https://example.com");
expect(state.lastRedirect).toBeUndefined();
});
it("refuses without network:outbound", async () => {
const ctx = contextFor([]);
await expect(ctx.fetch("https://example.com")).rejects.toThrow(
/network:outbound/,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_fetch",
success: false,
});
});
it("goes through the SSRF guard with the listed private hosts only", async () => {
const ctx = contextFor(["network:outbound"]);
const response = await ctx.fetch("https://ntfy.lan/topic", {
method: "POST",
body: "hi",
allowPrivateHosts: ["NTFY.LAN"],
});
expect(await response.text()).toBe("ok");
expect(state.fetchCalls).toEqual([
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
]);
});
it("passes a pinned CA and the fingerprint bootstrap flag to the guard", async () => {
const ctx = contextFor(["network:outbound"]);
await ctx.fetch("https://ca.lan/root/abc", {
tls: { rejectUnauthorized: false },
});
expect(state.lastTls).toEqual({ rejectUnauthorized: false });
await ctx.fetch("https://ca.lan/provisioners", { tls: { ca: "PEM" } });
expect(state.lastTls).toEqual({ ca: "PEM" });
await ctx.fetch("https://example.com");
expect(state.lastTls).toEqual({});
});
it("hands the caller's timeout to the guard for the body idle timeout", async () => {
const ctx = contextFor(["network:outbound"]);
await ctx.fetch("https://example.com/stream", { timeoutMs: 600_000 });
expect(state.lastIdleTimeout).toBe(600_000);
});
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);
});
});