feat: finish the ai plugin (B18)

This commit is contained in:
LukeGus committed 2026-09-24 13:43:26 -05:00
1 parent 6b4d1432ab
commit 21937a6b02
193 files changed
+23429 -4244

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { activate } from "../../src/backend/index.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("ai activate", () => {
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
},
);
it("cannot store a provider key without secrets:own", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
db: db.database,
router: () => express.Router(),
});
await activate(mock.ctx);
await expect(
mock.ctx.secrets.set("provider:1", "k"),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
it("offers the api key and the host import normalizer", async () => {
server = await startServer();
await server.enableFor("user-1");
await server.request("POST", "/providers", {
body: { providerType: "openai", label: "Work", apiKey: "sk-shared" },
});
const normalizer = server.mock.ctx.registry.consume<
(raw: Record<string, unknown>) => Record<string, unknown> | null
>("ai.hostImportNormalizer");
expect(normalizer?.({ enableAiAssistant: true })).toEqual({
enableAiAssistant: true,
});
expect(
normalizer?.({ pluginSettings: { ai: { enableAiAssistant: false } } }),
).toEqual({ enableAiAssistant: false });
expect(normalizer?.({ name: "x" })).toBeNull();
});
});
@@ -1,39 +1,60 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
listCommandHistory,
setPluginServices,
readService,
requireService,
serviceAvailable,
SERVICE,
} from "../../src/backend/services.js";
afterEach(() => setPluginServices(null));
function services(provided: Record<string, object>) {
return {
providers: (service: string) => (service in provided ? [""] : []),
get: vi.fn((service: string) => provided[service]),
} as never;
}
describe("listCommandHistory", () => {
describe("optional services", () => {
it("reads the user's history through terminal.history", async () => {
const list = vi.fn(async () => [
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
]);
const get = vi.fn(() => ({ list }));
setPluginServices({ get, provide: vi.fn() } as never);
const history = await readService(
services({ [SERVICE.history]: { list } }),
SERVICE.history,
(terminal) => terminal.list(4, 25),
);
const history = await listCommandHistory("user-1", 4, 25);
expect(get).toHaveBeenCalledWith("terminal.history", { userId: "user-1" });
expect(list).toHaveBeenCalledWith(4, 25);
expect(history).toEqual([
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
]);
});
it("answers null while the terminal is off or refuses the user", async () => {
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
it("answers null while the provider is off or refuses the user", async () => {
expect(
await readService(services({}), SERVICE.history, (terminal) =>
terminal.list(4, 25),
),
).toBeNull();
setPluginServices({
get: () => ({
list: async () => {
throw new Error("unavailable");
},
}),
provide: vi.fn(),
} as never);
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
const refusing = {
list: async () => {
throw new Error("Missing permission");
},
};
expect(
await readService(
services({ [SERVICE.history]: refusing }),
SERVICE.history,
(terminal) => terminal.list(4, 25),
),
).toBeNull();
});
it("says which plugin is missing when a write needs it", () => {
expect(serviceAvailable(services({}), SERVICE.fleets)).toBe(false);
expect(() => requireService(services({}), SERVICE.fleets)).toThrow(
"The fleets plugin is not available",
);
});
});
+9 -9
View File
@@ -101,22 +101,22 @@ describe("evaluateEgress", () => {
});
describe("parseAllowlist", () => {
it("falls back to the defaults when unset or malformed", () => {
it("falls back to the defaults when the setting is not text", () => {
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist(undefined)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
});
it("normalises stored entries", () => {
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([
"localhost",
"10.0.0.5",
]);
it("reads one host per line, normalised, skipping anything that is not a host", () => {
expect(
parseAllowlist(
[" LocalHost ", "", "10.0.0.5", "http://x/y", "10.0.0.5"].join("\n"),
),
).toEqual(["localhost", "10.0.0.5"]);
});
it("honours a deliberately empty allowlist", () => {
// An admin clearing the list must actually block everything private,
// not silently get the defaults back.
expect(parseAllowlist("[]")).toEqual([]);
expect(parseAllowlist("")).toEqual([]);
});
});
+12 -17
View File
@@ -8,22 +8,6 @@ vi.mock("../../src/backend/providers/registry.js", () => ({
getAdapter: () => ({ streamChat, listModels: async () => [] }),
}));
vi.mock("../../src/backend/tools/catalog.js", () => ({
getTool: (name: string) =>
name === "list_hosts"
? {
name: "list_hosts",
description: "List hosts",
category: "read",
parameters: { type: "object", properties: {} },
handler,
}
: undefined,
toolDefinitions: () => [
{ name: "list_hosts", description: "List hosts", parameters: {} },
],
}));
const { runAgent } = await import("../../src/backend/engine.js");
function chunks(...values: ChatChunk[]) {
@@ -33,14 +17,25 @@ function chunks(...values: ChatChunk[]) {
}
const BASE = {
config: { providerType: "ollama" as const },
config: { providerType: "ollama" as const, fetch: vi.fn() },
model: "test",
system: "system",
context: {
userId: "user-1",
conversationId: 1,
allowReadOnlyCommands: false,
deps: {} as never,
},
// Only what was offered can run, so this is the whole catalog here.
tools: [
{
name: "list_hosts",
description: "List hosts",
category: "read" as const,
parameters: { type: "object", properties: {} },
handler,
},
],
};
async function collect(history: any[] = []) {
+109 -76
View File
@@ -1,94 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
createAiGate,
isAiGloballyEnabled,
readPrivateAllowlist,
resolveAiAccess,
} from "../../src/backend/gating.js";
import { DEFAULT_PRIVATE_ALLOWLIST } from "../../src/backend/egress.js";
const settingsRepository = { getBoolean: vi.fn() };
const userPreferenceRepository = { findByUserId: vi.fn() };
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => settingsRepository,
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
}));
const { isAiGloballyEnabled, resolveAiAccess } =
await import("../../src/backend/gating.js");
/** The plugin's settings as ctx.settings reads them: admin, then per user. */
function settings(
admin: Record<string, unknown>,
user: Record<string, unknown> = {},
) {
return {
get: vi.fn(async (key: string) => admin[key]) as never,
getUser: vi.fn(async (_userId: string, key: string) => user[key]) as never,
};
}
describe("AI gating", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults to off so upgrading an install enables nothing", async () => {
settingsRepository.getBoolean.mockResolvedValue(false);
await isAiGloballyEnabled();
expect(settingsRepository.getBoolean).toHaveBeenCalledWith(
"ai_globally_enabled",
false,
it("is off until an admin turns it on, so an upgrade enables nothing", async () => {
expect(await isAiGloballyEnabled(settings({}))).toBe(false);
expect(await isAiGloballyEnabled(settings({ globallyEnabled: true }))).toBe(
true,
);
});
it("blocks everyone when the admin global is off", async () => {
settingsRepository.getBoolean.mockResolvedValue(false);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: true,
});
it("blocks everyone when the admin switch is off", async () => {
const reader = settings(
{ globallyEnabled: false },
{ enabled: true, allowReadOnlyCommands: true },
);
const access = await resolveAiAccess("user-1");
const access = await resolveAiAccess(reader, "user-1");
expect(access).toEqual({ enabled: false, allowReadOnlyCommands: false });
// The kill switch short-circuits, so the user's choice is never read.
expect(reader.getUser).not.toHaveBeenCalled();
});
it("treats a user who never chose as not enabled", async () => {
const access = await resolveAiAccess(
settings({ globallyEnabled: true }),
"user-1",
);
expect(access.enabled).toBe(false);
expect(access.allowReadOnlyCommands).toBe(false);
// The kill switch short-circuits, so the preference is never consulted.
expect(userPreferenceRepository.findByUserId).not.toHaveBeenCalled();
});
it("blocks a user who has not enabled it", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: false,
});
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("treats never-asked as not enabled", async () => {
// Null means the user was never shown the choice, which is not consent.
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: null,
});
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("treats a missing preference row as not enabled", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue(null);
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("allows only when both gates are open", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: true,
});
const access = await resolveAiAccess("user-1");
expect(access.enabled).toBe(true);
expect(access.allowReadOnlyCommands).toBe(true);
const access = await resolveAiAccess(
settings(
{ globallyEnabled: true },
{ enabled: true, allowReadOnlyCommands: true },
),
"user-1",
);
expect(access).toEqual({ enabled: true, allowReadOnlyCommands: true });
});
it("keeps read-only commands off unless separately opted in", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: null,
});
const access = await resolveAiAccess("user-1");
expect(access.enabled).toBe(true);
const access = await resolveAiAccess(
settings({ globallyEnabled: true }, { enabled: true }),
"user-1",
);
expect(access.allowReadOnlyCommands).toBe(false);
});
it("reads the private endpoint list, with defaults when unset", async () => {
expect(await readPrivateAllowlist(settings({}))).toEqual(
DEFAULT_PRIVATE_ALLOWLIST,
);
expect(
await readPrivateAllowlist(
settings({ privateEndpoints: "ollama.lan\n10.0.0.5" }),
),
).toEqual(["ollama.lan", "10.0.0.5"]);
});
});
describe("createAiGate", () => {
function response() {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
res.statusCode = code;
return { json: (body: unknown) => (res.body = body) };
},
};
return res;
}
it("answers 401 without a user and 403 while the assistant is off", async () => {
const next = vi.fn();
const noUser = response();
await createAiGate(settings({ globallyEnabled: true }), () => undefined)(
{},
noUser,
next,
);
expect(noUser.statusCode).toBe(401);
const off = response();
await createAiGate(settings({ globallyEnabled: false }), () => "user-1")(
{},
off,
next,
);
expect(off.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
it("passes an opted-in user through with their access attached", async () => {
const next = vi.fn();
const req: { aiAccess?: unknown } = {};
await createAiGate(
settings({ globallyEnabled: true }, { enabled: true }),
() => "user-1",
)(req, response(), next);
expect(next).toHaveBeenCalledWith();
expect(req.aiAccess).toEqual({
enabled: true,
allowReadOnlyCommands: false,
});
});
});
+181
View File
@@ -0,0 +1,181 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export const ALL_PERMISSIONS = [
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
];
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
/** Turns the assistant on for the server and opts `userId` in. */
enableFor: (userId: string) => Promise<void>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
services?: Record<string, object>;
fetch?: (url: string, init?: unknown) => Promise<Response>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ALL_PERMISSIONS,
services: options.services,
fetch: options.fetch as never,
hosts: [
{
id: 1,
userId: "user-1",
name: "web-1",
ip: "10.0.0.1",
port: 22,
username: "root",
tags: "prod",
folder: null,
authType: "password",
},
],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { status: response.status, body: parsed };
},
async enableFor(userId) {
await mock.ctx.settings.set("globallyEnabled", true);
await mock.ctx.settings.setUser(userId, "enabled", true);
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
// The four tables as core's SQLite bootstrap created them before 2.9.0, with
// the indexes performance-indexes.ts added.
export const LEGACY_DDL = `
CREATE TABLE ai_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL,
label TEXT NOT NULL,
base_url TEXT,
api_key TEXT,
api_key_prefix TEXT,
default_model TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, label)
);
CREATE TABLE ai_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT,
provider_id INTEGER,
model TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ai_proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
summary TEXT,
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
applied_at TEXT,
result_summary TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_ai_providers_user_label ON ai_providers (user_id, label);
CREATE INDEX idx_ai_conversations_user ON ai_conversations (user_id, updated_at);
CREATE INDEX idx_ai_messages_conversation ON ai_messages (conversation_id, created_at);
CREATE INDEX idx_ai_proposals_user ON ai_proposals (user_id, status);
CREATE INDEX idx_ai_proposals_conversation ON ai_proposals (conversation_id);
`;
+118
View File
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { LEGACY_DDL, pluginDir } from "./helpers";
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tables = (sqlite: TestDb["sqlite"]) =>
(
sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%ai_%' ORDER BY name",
)
.all() as Array<{ name: string }>
).map((row) => row.name);
const indexes = (sqlite: TestDb["sqlite"], table: string) =>
(
sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name LIKE 'idx_%' ORDER BY name",
)
.all(table) as Array<{ name: string }>
).map((row) => row.name);
describe("ai adoption migration", () => {
it("renames the legacy tables and keeps every row, index and cascade", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(LEGACY_DDL);
sqlite
.prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')")
.run();
sqlite.exec(`
INSERT INTO ai_providers (id, user_id, provider_type, label, api_key, api_key_prefix)
VALUES (3, 'u1', 'ollama', 'Local', 'enc-key', 'abc123');
INSERT INTO ai_conversations (id, user_id, title) VALUES (5, 'u1', 'Disk');
INSERT INTO ai_messages (conversation_id, role, content) VALUES (5, 'user', 'why full');
INSERT INTO ai_proposals (conversation_id, user_id, kind) VALUES (5, 'u1', 'propose_run_command');
`);
},
});
expect(tables(db.sqlite)).toEqual([
"p_ai_conversations",
"p_ai_messages",
"p_ai_proposals",
"p_ai_providers",
]);
const provider = db.sqlite
.prepare("SELECT * FROM p_ai_providers WHERE id = 3")
.get() as Record<string, unknown>;
// The key stays until core's boot migration moves it into ctx.secrets.
expect(provider).toMatchObject({ label: "Local", api_key: "enc-key" });
expect(indexes(db.sqlite, "p_ai_providers")).toEqual([
"idx_ai_providers_user_label",
]);
expect(indexes(db.sqlite, "p_ai_proposals")).toEqual([
"idx_ai_proposals_conversation",
"idx_ai_proposals_user",
]);
// The rename carried the foreign keys: a conversation still takes its
// messages and proposals with it.
db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 5").run();
const count = (table: string) =>
(
db!.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
n: number;
}
).n;
expect(count("p_ai_messages")).toBe(0);
expect(count("p_ai_proposals")).toBe(0);
});
it("creates fresh tables when there is nothing to adopt", async () => {
db = await createTestDb(pluginDir);
expect(tables(db.sqlite)).toEqual([
"p_ai_conversations",
"p_ai_messages",
"p_ai_proposals",
"p_ai_providers",
]);
const columns = (
db.sqlite.prepare("PRAGMA table_info(p_ai_providers)").all() as Array<{
name: string;
}>
).map((column) => column.name);
expect(columns).not.toContain("api_key");
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES ('u1', 'u1')")
.run();
db.sqlite
.prepare("INSERT INTO p_ai_conversations (id, user_id) VALUES (1, 'u1')")
.run();
db.sqlite
.prepare(
"INSERT INTO p_ai_messages (conversation_id, role) VALUES (1, 'user')",
)
.run();
db.sqlite.prepare("DELETE FROM p_ai_conversations WHERE id = 1").run();
expect(
(
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_ai_messages").get() as {
n: number;
}
).n,
).toBe(0);
});
});
@@ -1,49 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createProviderFetch } from "../../../src/backend/providers/http.js";
import { PRIVATE_DESTINATION_MESSAGE } from "../../../src/backend/egress.js";
const fetchWithProxy = vi.fn();
const getFetchDispatcher = vi.fn();
const safeOutboundFetch = vi.fn();
const readPrivateAllowlist = vi.fn();
const evaluateEgress = vi.fn();
const globalFetch = vi.fn();
vi.mock("../../../../../src/backend/utils/proxy-agent.js", () => ({
fetchWithProxy,
getFetchDispatcher,
}));
vi.mock("../../../../../src/backend/utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch,
}));
vi.mock("../../../src/backend/egress.js", () => ({
readPrivateAllowlist,
evaluateEgress,
}));
const { providerFetch } =
await import("../../../src/backend/providers/http.js");
describe("providerFetch", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", globalFetch);
readPrivateAllowlist.mockResolvedValue(["192.168.1.50"]);
});
it("uses the matching Undici fetch implementation for private providers", async () => {
const response = { ok: true, status: 200 } as Response;
const init = { method: "GET" };
evaluateEgress.mockReturnValue({ allowed: true, isPrivate: true });
fetchWithProxy.mockResolvedValue(response);
describe("createProviderFetch", () => {
it("sends provider requests through ctx.fetch with the admin allowlist", async () => {
const response = new Response("{}");
const fetch = vi.fn(async () => response);
const controller = new AbortController();
const providerFetch = createProviderFetch(fetch, ["192.168.1.50"]);
await expect(
providerFetch("http://192.168.1.50:11434/api/tags", init),
providerFetch("http://192.168.1.50:11434/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
signal: controller.signal,
}),
).resolves.toBe(response);
expect(fetchWithProxy).toHaveBeenCalledWith(
"http://192.168.1.50:11434/api/tags",
init,
expect(fetch).toHaveBeenCalledWith(
"http://192.168.1.50:11434/api/chat",
expect.objectContaining({
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
signal: controller.signal,
allowPrivateHosts: ["192.168.1.50"],
}),
);
expect(globalFetch).not.toHaveBeenCalled();
expect(safeOutboundFetch).not.toHaveBeenCalled();
});
it("refuses a private address the admin has not allowed, before any request", async () => {
const fetch = vi.fn();
const providerFetch = createProviderFetch(fetch, ["localhost"]);
await expect(
providerFetch("http://10.0.0.9:11434/api/tags", { method: "GET" }),
).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE);
expect(fetch).not.toHaveBeenCalled();
});
it("explains a hostname that resolved to a private address", async () => {
const fetch = vi.fn(async () => {
throw new Error("Private destinations are not allowed");
});
const providerFetch = createProviderFetch(fetch, []);
await expect(
providerFetch("https://ollama.example.com/api/tags", { method: "GET" }),
).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE);
});
it("passes other failures through unchanged", async () => {
const fetch = vi.fn(async () => {
throw new Error("socket hang up");
});
const providerFetch = createProviderFetch(fetch, []);
await expect(
providerFetch("https://api.openai.com/v1/models", { method: "GET" }),
).rejects.toThrow("socket hang up");
});
});
@@ -1,15 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatChunk } from "../../../src/backend/providers/types.js";
// Adapters get the egress-checked fetch through their config.
const providerFetch = vi.fn();
vi.mock("../../../src/backend/providers/http.js", async () => {
const actual = await vi.importActual<
typeof import("../../../src/backend/providers/http.js")
>("../../../src/backend/providers/http.js");
return { ...actual, providerFetch };
});
const { openAiAdapter } =
await import("../../../src/backend/providers/openai.js");
const { ollamaAdapter } =
@@ -69,7 +63,10 @@ describe("openAiAdapter", () => {
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
openAiAdapter.streamChat(
{ fetch: providerFetch, providerType: "openai" },
REQUEST,
),
);
expect(chunks.filter((c) => c.type === "text")).toEqual([
@@ -89,7 +86,10 @@ describe("openAiAdapter", () => {
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
openAiAdapter.streamChat(
{ fetch: providerFetch, providerType: "openai" },
REQUEST,
),
);
const call = chunks.find((c) => c.type === "tool_call");
@@ -110,7 +110,10 @@ describe("openAiAdapter", () => {
);
const chunks = await collect(
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
openAiAdapter.streamChat(
{ fetch: providerFetch, providerType: "openai" },
REQUEST,
),
);
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
@@ -122,7 +125,7 @@ describe("openAiAdapter", () => {
await expect(
collect(
openAiAdapter.streamChat(
{ providerType: "openai_compatible" },
{ fetch: providerFetch, providerType: "openai_compatible" },
REQUEST,
),
),
@@ -143,7 +146,10 @@ describe("ollamaAdapter", () => {
);
const chunks = await collect(
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
ollamaAdapter.streamChat(
{ fetch: providerFetch, providerType: "ollama" },
REQUEST,
),
);
expect(chunks.filter((c) => c.type === "text")).toHaveLength(2);
@@ -160,7 +166,10 @@ describe("ollamaAdapter", () => {
);
const chunks = await collect(
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
ollamaAdapter.streamChat(
{ fetch: providerFetch, providerType: "ollama" },
REQUEST,
),
);
const calls = chunks.filter((c) => c.type === "tool_call") as any[];
@@ -196,7 +205,7 @@ describe("geminiAdapter", () => {
const chunks = await collect(
geminiAdapter.streamChat(
{ providerType: "gemini", apiKey: "k" },
{ fetch: providerFetch, providerType: "gemini", apiKey: "k" },
REQUEST,
),
);
@@ -211,7 +220,7 @@ describe("geminiAdapter", () => {
await collect(
geminiAdapter.streamChat(
{ providerType: "gemini", apiKey: "k" },
{ fetch: providerFetch, providerType: "gemini", apiKey: "k" },
{
...REQUEST,
messages: [
@@ -271,7 +280,12 @@ describe("assertOk error messages", () => {
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
collect(
openAiAdapter.streamChat(
{ fetch: providerFetch, providerType: "openai" },
REQUEST,
),
),
).rejects.toThrow("Function call is missing a signature.");
});
@@ -284,7 +298,12 @@ describe("assertOk error messages", () => {
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
collect(
openAiAdapter.streamChat(
{ fetch: providerFetch, providerType: "openai" },
REQUEST,
),
),
).rejects.toThrow(/rate limit reached/i);
});
@@ -294,7 +313,12 @@ describe("assertOk error messages", () => {
);
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
collect(
openAiAdapter.streamChat(
{ fetch: providerFetch, providerType: "openai" },
REQUEST,
),
),
).rejects.toThrow(/rejected the API key/i);
});
@@ -302,7 +326,12 @@ describe("assertOk error messages", () => {
providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded"));
await expect(
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
collect(
openAiAdapter.streamChat(
{ fetch: providerFetch, providerType: "openai" },
REQUEST,
),
),
).rejects.toThrow("upstream exploded");
});
});
+216
View File
@@ -0,0 +1,216 @@
import { afterEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
describe("ai routes", () => {
it("reports status without the gate, and blocks everything else while off", async () => {
server = await startServer();
const status = await server.request("GET", "/status");
expect(status.status).toBe(200);
expect(status.body).toEqual({
globallyEnabled: false,
enabled: false,
allowReadOnlyCommands: false,
});
expect((await server.request("GET", "/providers")).status).toBe(403);
});
it("lets a user opt in only while the server allows it", async () => {
server = await startServer();
const refused = await server.request("PUT", "/opt-in", {
body: { enabled: true },
});
expect(refused.status).toBe(403);
await server.mock.ctx.settings.set("globallyEnabled", true);
const accepted = await server.request("PUT", "/opt-in", {
body: { enabled: true },
});
expect(accepted.status).toBe(200);
expect(await server.mock.ctx.settings.getUser("user-1", "enabled")).toBe(
true,
);
});
it("keeps a provider's key in ctx.secrets, never in the row or the response", async () => {
server = await startServer();
await server.enableFor("user-1");
const created = await server.request("POST", "/providers", {
body: {
providerType: "openai",
label: "Work",
apiKey: "sk-secret-value",
defaultModel: "gpt-5",
},
});
expect(created.status).toBe(201);
expect(created.body.provider.apiKeyPrefix).toBe("sk-sec");
expect(JSON.stringify(created.body)).not.toContain("sk-secret-value");
const id = created.body.provider.id;
expect(server.mock.secretStore.get(`user-1:provider:${id}`)).toBe(
"sk-secret-value",
);
const row = server.db.sqlite
.prepare("SELECT * FROM p_ai_providers WHERE id = ?")
.get(id) as Record<string, unknown>;
expect(JSON.stringify(row)).not.toContain("sk-secret-value");
const list = await server.request("GET", "/providers");
expect(list.body.providers).toHaveLength(1);
// Another user sees none of it.
await server.enableFor("user-2");
const other = await server.request("GET", "/providers", { user: "user-2" });
expect(other.body.providers).toEqual([]);
const removed = await server.request("DELETE", `/providers/${id}`);
expect(removed.status).toBe(200);
expect(server.mock.secretStore.has(`user-1:provider:${id}`)).toBe(false);
});
it("validates a new provider", async () => {
server = await startServer();
await server.enableFor("user-1");
const unknown = await server.request("POST", "/providers", {
body: { providerType: "nope", label: "x" },
});
expect(unknown.status).toBe(400);
const noKey = await server.request("POST", "/providers", {
body: { providerType: "anthropic", label: "x" },
});
expect(noKey.status).toBe(400);
});
it("rejects and applies proposals only once, for their owner", async () => {
server = await startServer();
await server.enableFor("user-1");
server.db.sqlite
.prepare(
"INSERT INTO p_ai_conversations (id, user_id, title) VALUES (1, 'user-1', 't')",
)
.run();
server.db.sqlite
.prepare(
`INSERT INTO p_ai_proposals (id, conversation_id, user_id, kind, payload)
VALUES (1, 1, 'user-1', 'propose_create_host', '{"name":"db","ip":"10.0.0.2"}'),
(2, 1, 'user-1', 'propose_delete_host', '{"hostId":1}')`,
)
.run();
// Not theirs.
await server.enableFor("user-2");
expect(
(await server.request("POST", "/proposals/1/reject", { user: "user-2" }))
.status,
).toBe(404);
const rejected = await server.request("POST", "/proposals/2/reject");
expect(rejected.status).toBe(200);
expect((await server.request("POST", "/proposals/2/reject")).status).toBe(
404,
);
const conversation = await server.request("GET", "/conversations/1");
expect(conversation.status).toBe(200);
expect(
conversation.body.proposals.map((p: { id: number }) => p.id),
).toEqual([2, 1]);
const deleted = await server.request("DELETE", "/conversations/1");
expect(deleted.status).toBe(200);
const left = server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_ai_proposals")
.get() as { n: number };
expect(left.n).toBe(0);
});
it.each([
["GET", "/providers", "ai.use"],
["POST", "/providers", "ai.manage_providers"],
["PATCH", "/providers/1", "ai.manage_providers"],
["DELETE", "/providers/1", "ai.manage_providers"],
["POST", "/probe-models", "ai.manage_providers"],
["GET", "/providers/1/models", "ai.use"],
["GET", "/conversations", "ai.use"],
["GET", "/conversations/1", "ai.use"],
["DELETE", "/conversations/1", "ai.use"],
["POST", "/chat/stream", "ai.use"],
["POST", "/proposals/1/apply", "ai.apply_proposals"],
["POST", "/proposals/1/mark-run-in-terminal", "ai.apply_proposals"],
["POST", "/proposals/1/reject", "ai.use"],
["PUT", "/opt-in", "ai.use"],
])("%s %s answers 403 without %s", async (method, path, permission) => {
server = await startServer({
permissions: [
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
].filter((entry) => entry !== permission),
});
await server.enableFor("user-1");
const response = await server.request(method, path, { body: {} });
expect(response.status).toBe(403);
});
});
describe("chat stream", () => {
it("offers the model only the tools whose plugins are running", async () => {
const bodies: Array<{ tools?: Array<{ function?: { name: string } }> }> =
[];
const encoder = new TextEncoder();
server = await startServer({
services: { "snippets.access": { list: async () => [] } },
fetch: async (_url, init) => {
bodies.push(JSON.parse((init as { body: string }).body));
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n',
),
);
controller.close();
},
});
return new Response(stream, { status: 200 });
},
});
await server.enableFor("user-1");
const created = await server.request("POST", "/providers", {
body: {
providerType: "openai",
label: "Work",
apiKey: "sk-test",
defaultModel: "gpt-5",
},
});
const reply = await server.request("POST", "/chat/stream", {
body: { providerId: created.body.provider.id, message: "hello" },
});
expect(reply.status).toBe(200);
expect(String(reply.body)).toContain('"type":"done"');
const offered = (bodies[0].tools ?? []).map((tool) => tool.function?.name);
expect(offered).toContain("list_hosts");
expect(offered).toContain("list_snippets");
expect(offered).not.toContain("list_fleets");
expect(offered).not.toContain("list_homepage_items");
expect(offered).not.toContain("propose_create_automation");
});
});
@@ -1,76 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hostRepository = {
create: vi.fn(),
findByIdForUser: vi.fn(),
updateForUser: vi.fn(),
deleteForUser: vi.fn(),
};
const snippetRepository = {
createSnippet: vi.fn(),
findOwnedById: vi.fn(),
updateSnippet: vi.fn(),
deleteSnippet: vi.fn(),
};
const automationsAccess = { create: vi.fn() };
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
vi.mock("../../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentHostRepository: () => hostRepository,
createCurrentSnippetRepository: () => snippetRepository,
}));
const resolveHostById = vi.fn();
vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
}));
const execCommand = vi.fn();
vi.mock("@termix/plugin-sdk/host-commands", async (importOriginal) => ({
...(await importOriginal<object>()),
execCommand: (...args: unknown[]) => execCommand(...args),
}));
vi.mock("../../../src/backend/ssh.js", () => ({
withSshConnection: async (
_host: unknown,
_options: unknown,
run: (client: unknown) => Promise<unknown>,
) => run({}),
}));
const { applyProposal } =
await import("../../../src/backend/tools/executor.js");
const { setPluginServices } = await import("../../../src/backend/services.js");
const hosts = {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
checkAccess: vi.fn(),
};
const automationsAccess = { create: vi.fn() };
const fleetsAccess = { create: vi.fn(), addMember: vi.fn() };
const snippetsAccess = {
create: vi.fn(),
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
};
const withConnection = vi.fn(
async (
_host: unknown,
_options: unknown,
run: (client: unknown) => unknown,
) => run({}),
);
let provided: Record<string, object> = {};
let granted = new Set<string>();
/** What the executor gets from ctx: the approving user's view of core. */
function deps() {
return {
hosts,
services: {
providers: (service: string) => (service in provided ? [""] : []),
get: (service: string) => provided[service],
},
rbac: { has: async (permission: string) => granted.has(permission) },
ssh: { withConnection },
notify: {},
} as never;
}
beforeEach(() => {
vi.clearAllMocks();
provided = {};
granted = new Set(["hosts.create", "hosts.edit"]);
});
describe("applyProposal", () => {
beforeEach(() => {
vi.clearAllMocks();
setPluginServices(null);
});
it("refuses a kind that is not a real tool", async () => {
// The stored payload is treated as untrusted even though the server wrote
// it, because a proposal can outlive the release that created it.
await expect(
applyProposal("propose_delete_everything", {}, "user-1"),
applyProposal("propose_delete_everything", {}, deps()),
).rejects.toThrow("Unknown proposal kind");
});
function withAutomations() {
setPluginServices({
get: (service: string) => {
if (service !== "automations.access")
throw new Error(`unexpected ${service}`);
return automationsAccess as never;
},
provide: vi.fn(),
} as never);
}
it("hands an automation to the automations plugin, which validates it", async () => {
// The plugin runs its own route's validator, so a definition the model
// invented is held to the same standard as a hand-written one.
withAutomations();
provided["automations.access"] = automationsAccess;
automationsAccess.create.mockRejectedValue(
new Error("Unknown or missing trigger kind"),
);
@@ -81,7 +75,7 @@ describe("applyProposal", () => {
name: "bad",
definition: { trigger: { kind: "nonsense" }, steps: [] },
},
"user-1",
deps(),
),
).rejects.toThrow("Unknown or missing trigger kind");
});
@@ -91,13 +85,13 @@ describe("applyProposal", () => {
applyProposal(
"propose_create_automation",
{ name: "x", definition: {} },
"user-1",
deps(),
),
).rejects.toThrow("The automations plugin is not available");
});
it("creates a valid automation disabled so it cannot fire unwatched", async () => {
withAutomations();
provided["automations.access"] = automationsAccess;
automationsAccess.create.mockResolvedValue({ id: 5, name: "nightly" });
const result = await applyProposal(
@@ -109,7 +103,7 @@ describe("applyProposal", () => {
steps: [{ id: "s1", type: "wait", seconds: 1 }],
},
},
"user-1",
deps(),
);
expect(result.ok).toBe(true);
@@ -118,119 +112,100 @@ describe("applyProposal", () => {
);
});
it("runs an approved command only on a host the user can reach", async () => {
// resolveHostById returns null when the connect-level permission check
// fails, so an unreachable host never gets as far as an SSH attempt.
resolveHostById.mockResolvedValue(null);
await expect(
applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
),
).rejects.toThrow("Host not found");
expect(execCommand).not.toHaveBeenCalled();
});
it("returns command output on success", async () => {
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
it("runs an approved command through ctx.ssh on the host id", async () => {
// ctx.ssh resolves the host for the approving user, with the connect
// access check, so the executor never holds a host row itself.
execCommand.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 });
const result = await applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
deps(),
);
expect(withConnection).toHaveBeenCalledWith(
9,
{ pool: "ai", purpose: "fleet" },
expect.any(Function),
);
expect(result.ok).toBe(true);
expect(result.summary).toContain("up 3 days");
});
it("resolves the host rather than using a raw repository row", async () => {
// A raw row has no decrypted auth and an unresolved jumpHosts field, which
// made the SSH factory fail with a jump host error on hosts that have none.
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
execCommand.mockResolvedValue({ stdout: "ok", stderr: "", code: 0 });
it("fails when the user cannot reach the host", async () => {
withConnection.mockRejectedValueOnce(new Error("Host not found"));
await applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
"user-1",
);
expect(resolveHostById).toHaveBeenCalledWith(9, "user-1");
expect(hostRepository.findByIdForUser).not.toHaveBeenCalled();
await expect(
applyProposal(
"propose_run_command",
{ hostId: 9, command: "uptime" },
deps(),
),
).rejects.toThrow("Host not found");
expect(execCommand).not.toHaveBeenCalled();
});
it("surfaces a non-zero exit rather than reporting success", async () => {
resolveHostById.mockResolvedValue({ id: 9, ip: "10.0.0.9" });
execCommand.mockResolvedValue({ stdout: "", stderr: "denied", code: 1 });
await expect(
applyProposal(
"propose_run_command",
{ hostId: 9, command: "cat /etc/shadow" },
"user-1",
deps(),
),
).rejects.toThrow("code 1");
});
it("creates a host through the normal repository", async () => {
hostRepository.create.mockResolvedValue({ id: 7, name: "web-1" });
it("creates a host through ctx.hosts, with tags as core stores them", async () => {
hosts.create.mockResolvedValue({ id: 7, name: "web-1" });
const result = await applyProposal(
"propose_create_host",
{ name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod"] },
"user-1",
{ name: "web-1", ip: "10.0.0.5", port: 22, tags: ["prod", "eu"] },
deps(),
);
expect(result.ok).toBe(true);
expect(hostRepository.create).toHaveBeenCalledWith(
expect(hosts.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
name: "web-1",
ip: "10.0.0.5",
tags: "prod,eu",
}),
);
});
it("refuses to create a host without hosts.create", async () => {
granted = new Set();
await expect(
applyProposal(
"propose_create_host",
{ name: "web-1", ip: "10.0.0.5" },
deps(),
),
).rejects.toThrow("permission");
expect(hosts.create).not.toHaveBeenCalled();
});
it("rejects a host payload missing required fields", async () => {
await expect(
applyProposal("propose_create_host", { name: "web-1" }, "user-1"),
applyProposal("propose_create_host", { name: "web-1" }, deps()),
).rejects.toThrow("ip is required");
expect(hostRepository.create).not.toHaveBeenCalled();
expect(hosts.create).not.toHaveBeenCalled();
});
it("scopes an update to the approving user", async () => {
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
hostRepository.updateForUser.mockResolvedValue({ id: 7 });
await applyProposal(
"propose_update_host",
{ hostId: 7, changes: { name: "renamed" } },
"user-1",
);
expect(hostRepository.findByIdForUser).toHaveBeenCalledWith("user-1", 7);
expect(hostRepository.updateForUser).toHaveBeenCalledWith(
"user-1",
7,
expect.objectContaining({ name: "renamed" }),
);
});
it("refuses to update a host the user does not own", async () => {
hostRepository.findByIdForUser.mockResolvedValue(null);
it("updates through ctx.hosts, which refuses a host the user does not own", async () => {
hosts.update.mockResolvedValue(null);
await expect(
applyProposal(
"propose_update_host",
{ hostId: 999, changes: { name: "x" } },
"user-1",
deps(),
),
).rejects.toThrow("Host not found");
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
expect(hosts.update).toHaveBeenCalledWith(999, { name: "x" });
});
it("rejects a non-numeric id rather than coercing it", async () => {
@@ -238,30 +213,46 @@ describe("applyProposal", () => {
applyProposal(
"propose_update_host",
{ hostId: "7; DROP TABLE hosts", changes: { name: "x" } },
"user-1",
deps(),
),
).rejects.toThrow("hostId must be a positive integer");
});
it("only adds fleet members the approving user owns", async () => {
setPluginServices({
get: (service: string) => {
if (service !== "fleets.access")
throw new Error(`unexpected ${service}`);
return fleetsAccess as never;
},
provide: vi.fn(),
} as never);
fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" });
hostRepository.findByIdForUser.mockImplementation(
async (_userId: string, hostId: number) =>
hostId === 1 ? { id: 1 } : null,
it("reports nothing to change on an empty update", async () => {
const result = await applyProposal(
"propose_update_host",
{ hostId: 7, changes: {} },
deps(),
);
expect(result.ok).toBe(false);
expect(hosts.update).not.toHaveBeenCalled();
});
it("deletes through ctx.hosts", async () => {
hosts.delete.mockResolvedValue(true);
const result = await applyProposal(
"propose_delete_host",
{ hostId: 7, reason: "old" },
deps(),
);
expect(result.ok).toBe(true);
expect(hosts.delete).toHaveBeenCalledWith(7);
});
it("only adds fleet members the approving user owns", async () => {
provided["fleets.access"] = fleetsAccess;
fleetsAccess.create.mockResolvedValue({ id: 3, name: "prod" });
hosts.checkAccess.mockImplementation(async (hostId: number) => ({
hasAccess: true,
isOwner: hostId === 1,
isShared: hostId !== 1,
}));
const result = await applyProposal(
"propose_create_fleet",
{ name: "prod", hostIds: [1, 2] },
"user-1",
deps(),
);
expect(fleetsAccess.addMember).toHaveBeenCalledTimes(1);
@@ -269,22 +260,22 @@ describe("applyProposal", () => {
expect(result.summary).toContain("1 host");
});
it("fails clearly when the fleets plugin is disabled", async () => {
it("fails clearly when the fleets plugin is off", async () => {
await expect(
applyProposal("propose_create_fleet", { name: "prod" }, "user-1"),
).rejects.toThrow("fleets plugin is disabled");
applyProposal("propose_create_fleet", { name: "prod" }, deps()),
).rejects.toThrow("The fleets plugin is not available");
});
it("reports nothing to change on an empty update", async () => {
hostRepository.findByIdForUser.mockResolvedValue({ id: 7 });
it("edits a snippet through the snippets plugin", async () => {
provided["snippets.access"] = snippetsAccess;
snippetsAccess.get.mockResolvedValue({ id: 4, name: "a" });
const result = await applyProposal(
"propose_update_host",
{ hostId: 7, changes: {} },
"user-1",
await applyProposal(
"propose_update_snippet",
{ snippetId: 4, changes: { name: "b" } },
deps(),
);
expect(result.ok).toBe(false);
expect(hostRepository.updateForUser).not.toHaveBeenCalled();
expect(snippetsAccess.update).toHaveBeenCalledWith(4, { name: "b" });
});
});