Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+517
@@ -0,0 +1,517 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user