518 lines
24 KiB
TypeScript
518 lines
24 KiB
TypeScript
import { promises as nodeFs } from "node:fs";
|
|
import type { FileHandle } from "node:fs/promises";
|
|
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
import type { PluginManifest } from "@toolctrl/plugin-runtime";
|
|
|
|
const getPluginForTenantMock = vi.fn();
|
|
const publishAuditEventViaOutboxMock = vi.fn();
|
|
const dbMock = {
|
|
$transaction: vi.fn(),
|
|
pluginUserConfig: {
|
|
findUnique: vi.fn(),
|
|
upsert: vi.fn(),
|
|
},
|
|
};
|
|
|
|
vi.mock("./PluginLifecycleService", () => ({
|
|
getPluginForTenant: (...args: unknown[]) => getPluginForTenantMock(...args),
|
|
}));
|
|
vi.mock("@toolctrl/db/audit-events.server", () => ({
|
|
publishAuditEventViaOutbox: (...args: unknown[]) => publishAuditEventViaOutboxMock(...args),
|
|
}));
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: dbMock,
|
|
}));
|
|
|
|
const baseManifest = {
|
|
schemaVersion: 1,
|
|
id: "com.acme.settings",
|
|
version: "1.0.0",
|
|
name: "Settings",
|
|
entry: "plugin.wasm",
|
|
wasmSha256: "0".repeat(64),
|
|
signature: "0".repeat(128),
|
|
signedBy: "0".repeat(64),
|
|
allowedHosts: [],
|
|
capabilities: [],
|
|
hooks: [{ name: "entity.created", export: "handleEntityCreated" }],
|
|
} satisfies PluginManifest;
|
|
|
|
const settingsHtml = "<main>Settings</main>";
|
|
const settingsHtmlSha256 = createHash("sha256").update(settingsHtml).digest("hex");
|
|
const settingsHtmlIntegrity = `sha256-${createHash("sha256").update(settingsHtml).digest("base64")}`;
|
|
|
|
function pluginFixture(manifest: PluginManifest = { ...baseManifest, ui: { settings: { entry: "ui/settings.html", sha256: settingsHtmlSha256 } } }) {
|
|
return {
|
|
id: "plugin-row-1",
|
|
pluginId: "com.acme.settings",
|
|
version: "1.0.0",
|
|
enabled: true,
|
|
manifest,
|
|
installedAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
getPluginForTenantMock.mockReset();
|
|
publishAuditEventViaOutboxMock.mockReset();
|
|
publishAuditEventViaOutboxMock.mockResolvedValue(undefined);
|
|
dbMock.$transaction.mockReset();
|
|
dbMock.$transaction.mockImplementation((callback: (tx: typeof dbMock) => unknown) => callback(dbMock));
|
|
dbMock.pluginUserConfig.findUnique.mockReset();
|
|
dbMock.pluginUserConfig.upsert.mockReset();
|
|
delete process.env.PLUGIN_BINARY_BASE_PATH;
|
|
});
|
|
|
|
describe("PluginSettingsService", () => {
|
|
test("loads settings HTML through tenant-scoped plugin lookup", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
|
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
|
await mkdir(bundleRoot, { recursive: true });
|
|
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
|
|
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
const result = await loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
|
|
|
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
|
expect(result.entry).toBe("ui/settings.html");
|
|
expect(result.trustedHtml).toBe(settingsHtml);
|
|
});
|
|
|
|
test("loads settings HTML declared with SRI integrity metadata", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
|
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
|
await mkdir(bundleRoot, { recursive: true });
|
|
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
|
|
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
|
getPluginForTenantMock.mockResolvedValue(
|
|
pluginFixture({ ...baseManifest, ui: { settings: { entry: "ui/settings.html", integrity: settingsHtmlIntegrity } } })
|
|
);
|
|
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
const result = await loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
|
|
|
expect(result.trustedHtml).toBe(settingsHtml);
|
|
});
|
|
|
|
test("rejects manifests without settings UI", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture(baseManifest));
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
|
|
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
|
code: "settings_ui_unavailable",
|
|
});
|
|
});
|
|
|
|
test("rejects settings UI manifests without HTML integrity metadata before touching the filesystem", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture({ ...baseManifest, ui: { settings: { entry: "ui/settings.html" } } as never }));
|
|
const openSpy = vi.spyOn(nodeFs, "open");
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
|
|
try {
|
|
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
|
code: "settings_ui_integrity_missing",
|
|
});
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
openSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
test("rejects unsafe entries before touching the filesystem", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
|
|
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
|
code: "settings_ui_entry_unsafe",
|
|
});
|
|
});
|
|
|
|
test("rejects settings HTML whose bytes differ from the manifest sha256", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
|
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
|
await mkdir(bundleRoot, { recursive: true });
|
|
await writeFile(path.join(bundleRoot, "settings.html"), "<main>Tampered</main>");
|
|
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
|
code: "settings_ui_integrity_mismatch",
|
|
});
|
|
});
|
|
|
|
test("rejects symlink traversal out of the plugin bundle", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
|
const outside = path.join(root, "outside.html");
|
|
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
|
await mkdir(bundleRoot, { recursive: true });
|
|
await writeFile(outside, "<main>Outside</main>");
|
|
await symlink(outside, path.join(bundleRoot, "settings.html"));
|
|
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
|
code: "settings_ui_entry_unsafe",
|
|
});
|
|
});
|
|
|
|
test("rejects oversized settings HTML after opening the file handle and before reading it into the route payload", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
|
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
|
const settingsPath = path.join(bundleRoot, "settings.html");
|
|
await mkdir(bundleRoot, { recursive: true });
|
|
await writeFile(settingsPath, settingsHtml);
|
|
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
const originalOpen = nodeFs.open.bind(nodeFs);
|
|
const openSpy = vi.spyOn(nodeFs, "open").mockImplementation(async (...args: Parameters<typeof nodeFs.open>) => {
|
|
const fileHandle = (await originalOpen(...args)) as FileHandle;
|
|
const originalStat = fileHandle.stat.bind(fileHandle);
|
|
vi.spyOn(fileHandle, "stat").mockImplementation(async (...statArgs: Parameters<FileHandle["stat"]>) => {
|
|
await writeFile(settingsPath, `<main>${"x".repeat(257 * 1024)}</main>`);
|
|
return originalStat(...statArgs);
|
|
});
|
|
return fileHandle;
|
|
});
|
|
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
|
|
|
try {
|
|
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
|
code: "settings_ui_file_too_large",
|
|
});
|
|
expect(openSpy).toHaveBeenCalledWith(expect.stringContaining("settings.html"), "r");
|
|
} finally {
|
|
openSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
test("loads per-user settings through tenant-scoped plugin lookup", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
dbMock.pluginUserConfig.findUnique.mockResolvedValue({
|
|
configJson: {
|
|
theme: "dark",
|
|
count: 2,
|
|
apiKey: "must-not-reach-frame",
|
|
password: "must-not-reach-frame",
|
|
"../escape": "unsafe",
|
|
__proto__: "polluted",
|
|
constructor: "unsafe",
|
|
prototype: "unsafe",
|
|
oversized: "x".repeat(33 * 1024),
|
|
tooManyItems: Array.from({ length: 513 }, (_, index) => index),
|
|
tooManyKeys: Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`k${index}`, index])),
|
|
nestedReserved: JSON.parse('{"safe":true,"__proto__":{"polluted":true}}'),
|
|
nestedSecret: { appearance: { apiKey: "must-not-reach-frame" } },
|
|
ignored: () => "nope",
|
|
},
|
|
});
|
|
const { loadPluginUserSettings } = await import("./PluginSettingsService");
|
|
|
|
const result = await loadPluginUserSettings({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" });
|
|
|
|
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
|
expect(dbMock.pluginUserConfig.findUnique).toHaveBeenCalledWith({
|
|
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
|
select: { configJson: true },
|
|
});
|
|
expect(result).toEqual({ theme: "dark", count: 2 });
|
|
expect(JSON.stringify(result)).not.toContain("must-not-reach-frame");
|
|
expect(result).not.toHaveProperty("apiKey");
|
|
expect(result).not.toHaveProperty("password");
|
|
expect(result).not.toHaveProperty("../escape");
|
|
expect(result).not.toHaveProperty("__proto__");
|
|
expect(result).not.toHaveProperty("constructor");
|
|
expect(result).not.toHaveProperty("prototype");
|
|
expect(result).not.toHaveProperty("oversized");
|
|
expect(result).not.toHaveProperty("tooManyItems");
|
|
expect(result).not.toHaveProperty("tooManyKeys");
|
|
expect(result).not.toHaveProperty("nestedReserved");
|
|
expect(result).not.toHaveProperty("nestedSecret");
|
|
});
|
|
|
|
test("loads settings page data with a single tenant plugin lookup", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
|
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
|
await mkdir(bundleRoot, { recursive: true });
|
|
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
|
|
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "dark" } });
|
|
|
|
const { loadPluginSettingsPageData } = await import("./PluginSettingsService");
|
|
const result = await loadPluginSettingsPageData({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" });
|
|
|
|
expect(getPluginForTenantMock).toHaveBeenCalledOnce();
|
|
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
|
expect(dbMock.pluginUserConfig.findUnique).toHaveBeenCalledWith({
|
|
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
|
select: { configJson: true },
|
|
});
|
|
expect(result).toMatchObject({
|
|
entry: "ui/settings.html",
|
|
trustedHtml: settingsHtml,
|
|
settings: { theme: "dark" },
|
|
plugin: { id: "plugin-row-1", pluginId: "com.acme.settings", version: "1.0.0" },
|
|
});
|
|
});
|
|
|
|
test("rejects per-user settings reads for disabled plugins before reading config", async () => {
|
|
getPluginForTenantMock.mockResolvedValue({ ...pluginFixture(), enabled: false });
|
|
const { loadPluginUserSettings } = await import("./PluginSettingsService");
|
|
|
|
await expect(loadPluginUserSettings({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" })).rejects.toMatchObject({
|
|
code: "plugin_disabled",
|
|
});
|
|
|
|
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects per-user settings reads when no safe settings UI is declared before reading config", async () => {
|
|
const { loadPluginUserSettings } = await import("./PluginSettingsService");
|
|
const args = { tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" };
|
|
|
|
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
|
|
await expect(loadPluginUserSettings(args)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
|
|
|
|
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
|
|
await expect(loadPluginUserSettings(args)).rejects.toMatchObject({ code: "settings_ui_entry_unsafe" });
|
|
|
|
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("upserts a single per-user setting without storing secret-like keys", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "dark" } });
|
|
dbMock.pluginUserConfig.upsert.mockResolvedValue({});
|
|
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
|
|
|
const result = await setPluginUserSetting({
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
userId: "user-1",
|
|
key: "layout.mode",
|
|
value: "compact",
|
|
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
|
});
|
|
|
|
expect(dbMock.pluginUserConfig.upsert).toHaveBeenCalledWith({
|
|
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
|
create: { pluginId: "plugin-row-1", userId: "user-1", configJson: { theme: "dark", "layout.mode": "compact" } },
|
|
update: { configJson: { theme: "dark", "layout.mode": "compact" } },
|
|
});
|
|
expect(dbMock.$transaction).toHaveBeenCalledOnce();
|
|
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(
|
|
dbMock,
|
|
expect.objectContaining({
|
|
tenantId: "tenant-1",
|
|
actorId: "user-1",
|
|
action: "plugin.configuration.updated",
|
|
targetId: "plugin-row-1",
|
|
metadata: { pluginId: "com.acme.settings", key: "layout.mode" },
|
|
})
|
|
);
|
|
expect(JSON.stringify(publishAuditEventViaOutboxMock.mock.calls[0]?.[1]?.metadata)).not.toContain("compact");
|
|
expect(result.settings).toEqual({ theme: "dark", "layout.mode": "compact" });
|
|
});
|
|
|
|
test("merges per-user settings from the write transaction snapshot", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "stale" } });
|
|
const tx = {
|
|
pluginUserConfig: {
|
|
findUnique: vi.fn().mockResolvedValue({ configJson: { theme: "dark", density: "comfortable" } }),
|
|
upsert: vi.fn().mockResolvedValue({}),
|
|
},
|
|
};
|
|
dbMock.$transaction.mockImplementationOnce((callback: (tx: typeof tx) => unknown) => callback(tx));
|
|
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
|
|
|
const result = await setPluginUserSetting({
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
userId: "user-1",
|
|
key: "layout.mode",
|
|
value: "compact",
|
|
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
|
});
|
|
|
|
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
|
expect(tx.pluginUserConfig.findUnique).toHaveBeenCalledWith({
|
|
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
|
select: { configJson: true },
|
|
});
|
|
expect(tx.pluginUserConfig.upsert).toHaveBeenCalledWith({
|
|
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
|
create: { pluginId: "plugin-row-1", userId: "user-1", configJson: { theme: "dark", density: "comfortable", "layout.mode": "compact" } },
|
|
update: { configJson: { theme: "dark", density: "comfortable", "layout.mode": "compact" } },
|
|
});
|
|
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(tx, expect.objectContaining({ action: "plugin.configuration.updated" }));
|
|
expect(result.settings).toEqual({ theme: "dark", density: "comfortable", "layout.mode": "compact" });
|
|
});
|
|
|
|
test("rolls back per-user setting writes when audit enqueue fails", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: {} });
|
|
dbMock.pluginUserConfig.upsert.mockResolvedValue({});
|
|
publishAuditEventViaOutboxMock.mockRejectedValueOnce(new Error("outbox unavailable"));
|
|
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
|
|
|
await expect(
|
|
setPluginUserSetting({
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
userId: "user-1",
|
|
key: "theme",
|
|
value: "dark",
|
|
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
|
})
|
|
).rejects.toThrow("outbox unavailable");
|
|
|
|
expect(dbMock.$transaction).toHaveBeenCalledOnce();
|
|
expect(dbMock.pluginUserConfig.upsert).toHaveBeenCalledOnce();
|
|
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("reports settings iframe violations as redacted tenant-scoped audit events", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
const { reportPluginSettingsFrameViolation } = await import("./PluginSettingsService");
|
|
|
|
await reportPluginSettingsFrameViolation({
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
reason: "wrong_origin",
|
|
actor: { userId: "user-1", email: "user@example.com", ip: "203.0.113.7" },
|
|
});
|
|
|
|
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
|
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(dbMock, {
|
|
tenantId: "tenant-1",
|
|
actorId: "user-1",
|
|
actorType: "user",
|
|
actorEmail: "user@example.com",
|
|
actorIp: "203.0.113.7",
|
|
action: "plugin.settings_frame.violation",
|
|
targetType: "plugin",
|
|
targetId: "plugin-row-1",
|
|
sourceModule: "plugin-settings",
|
|
outcome: "blocked",
|
|
dataSensitivity: "internal",
|
|
metadata: { pluginId: "com.acme.settings", pluginRowId: "plugin-row-1", reason: "wrong_origin", reportedByClient: true },
|
|
});
|
|
expect(JSON.stringify(publishAuditEventViaOutboxMock.mock.calls[0]?.[1])).not.toContain("payload");
|
|
});
|
|
|
|
test("rejects settings iframe violation reports for disabled plugins or missing settings UI", async () => {
|
|
const { reportPluginSettingsFrameViolation } = await import("./PluginSettingsService");
|
|
const base = {
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
reason: "invalid_payload" as const,
|
|
actor: { userId: "user-1", email: "user@example.com", ip: null },
|
|
};
|
|
|
|
getPluginForTenantMock.mockResolvedValueOnce({ ...pluginFixture(), enabled: false });
|
|
await expect(reportPluginSettingsFrameViolation(base)).rejects.toMatchObject({ code: "plugin_disabled" });
|
|
|
|
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
|
|
await expect(reportPluginSettingsFrameViolation(base)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
|
|
|
|
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects direct per-user setting writes for disabled plugins", async () => {
|
|
getPluginForTenantMock.mockResolvedValue({ ...pluginFixture(), enabled: false });
|
|
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
|
|
|
await expect(
|
|
setPluginUserSetting({
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
userId: "user-1",
|
|
key: "theme",
|
|
value: "dark",
|
|
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
|
})
|
|
).rejects.toMatchObject({ code: "plugin_disabled" });
|
|
|
|
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
|
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
|
|
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects direct per-user setting writes when no safe settings UI is declared", async () => {
|
|
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
|
const base = {
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
userId: "user-1",
|
|
key: "theme",
|
|
value: "dark",
|
|
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
|
};
|
|
|
|
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
|
|
await expect(setPluginUserSetting(base)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
|
|
|
|
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
|
|
await expect(setPluginUserSetting(base)).rejects.toMatchObject({ code: "settings_ui_entry_unsafe" });
|
|
|
|
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
|
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
|
|
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects unsafe, secret-like, invalid, and oversized setting writes", async () => {
|
|
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
|
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: {} });
|
|
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
|
const base = {
|
|
tenantId: "tenant-1",
|
|
pluginRowId: "plugin-row-1",
|
|
userId: "user-1",
|
|
actor: { userId: "user-1", email: null, ip: null },
|
|
};
|
|
|
|
await expect(setPluginUserSetting({ ...base, key: "../theme", value: "dark" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
|
await expect(setPluginUserSetting({ ...base, key: "k".repeat(129), value: "dark" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
|
await expect(setPluginUserSetting({ ...base, key: "__proto__", value: "polluted" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
|
await expect(setPluginUserSetting({ ...base, key: "constructor", value: "unsafe" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
|
await expect(setPluginUserSetting({ ...base, key: "prototype", value: "unsafe" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
|
await expect(setPluginUserSetting({ ...base, key: "apiKey", value: "abc" })).rejects.toMatchObject({ code: "settings_key_secret_like" });
|
|
await expect(setPluginUserSetting({ ...base, key: "theme", value: Number.NaN as never })).rejects.toMatchObject({ code: "settings_value_invalid" });
|
|
await expect(
|
|
setPluginUserSetting({ ...base, key: "deep", value: { a: { b: { c: { d: { e: { f: { g: { h: { i: true } } } } } } } } } })
|
|
).rejects.toMatchObject({
|
|
code: "settings_value_invalid",
|
|
});
|
|
await expect(setPluginUserSetting({ ...base, key: "array", value: Array.from({ length: 513 }, (_, index) => index) })).rejects.toMatchObject({
|
|
code: "settings_value_invalid",
|
|
});
|
|
await expect(
|
|
setPluginUserSetting({ ...base, key: "object", value: Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`k${index}`, index])) })
|
|
).rejects.toMatchObject({
|
|
code: "settings_value_invalid",
|
|
});
|
|
await expect(
|
|
setPluginUserSetting({ ...base, key: "nestedReserved", value: JSON.parse('{"theme":"dark","__proto__":{"polluted":true}}') })
|
|
).rejects.toMatchObject({
|
|
code: "settings_value_invalid",
|
|
});
|
|
await expect(setPluginUserSetting({ ...base, key: "appearance", value: { theme: { accessToken: "must-not-persist" } } })).rejects.toMatchObject({
|
|
code: "settings_value_invalid",
|
|
});
|
|
await expect(setPluginUserSetting({ ...base, key: "blob", value: "x".repeat(70 * 1024) })).rejects.toMatchObject({
|
|
code: "settings_value_too_large",
|
|
});
|
|
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
|
|
});
|
|
});
|