feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
claimCustomWidgetConfigurationRequestForUser,
|
||||
completeCustomWidgetConfigurationRequest,
|
||||
createCustomWidgetConfigurationRequest,
|
||||
getCustomWidgetConfigurationRequestForUser,
|
||||
releaseCustomWidgetConfigurationRequest,
|
||||
retryCustomWidgetConfigurationRequest,
|
||||
} from "../../custom-widget/configuration-requests";
|
||||
|
||||
describe("custom widget source configuration requests", () => {
|
||||
it("is user-scoped, one-time, and reports completion without returning configuration", async () => {
|
||||
const request = await createCustomWidgetConfigurationRequest({
|
||||
userId: "user-1",
|
||||
target: { type: "preview", id: "preview-1" },
|
||||
widgetName: "Printer",
|
||||
sourceId: "default",
|
||||
sourceName: "Printer API",
|
||||
source: { baseUrl: "http://printer.local", networkScope: "private", auth: "bearer" },
|
||||
kinds: ["apiKey"],
|
||||
});
|
||||
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-2")).toBeNull();
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "user-2")).toBeNull();
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
|
||||
userId: "user-1",
|
||||
status: "applying",
|
||||
});
|
||||
await releaseCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toBeNull();
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
|
||||
status: "applying",
|
||||
});
|
||||
await retryCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
|
||||
status: "applying",
|
||||
});
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toBeNull();
|
||||
await releaseCustomWidgetConfigurationRequest(request.id);
|
||||
await completeCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
|
||||
status: "completed",
|
||||
});
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("only reopens an applying request through the explicit pre-mutation retry transition", async () => {
|
||||
const request = await createCustomWidgetConfigurationRequest({
|
||||
userId: "retry-user",
|
||||
target: { type: "definition", id: "definition-1" },
|
||||
widgetName: "Printer",
|
||||
sourceId: "default",
|
||||
sourceName: "Printer API",
|
||||
source: { baseUrl: "http://printer.local", networkScope: "private", auth: "bearer" },
|
||||
kinds: ["apiKey"],
|
||||
});
|
||||
|
||||
await claimCustomWidgetConfigurationRequestForUser(request.id, "retry-user");
|
||||
await releaseCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "retry-user")).toBeNull();
|
||||
|
||||
await retryCustomWidgetConfigurationRequest(request.id);
|
||||
expect(await claimCustomWidgetConfigurationRequestForUser(request.id, "retry-user")).toMatchObject({
|
||||
status: "applying",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,982 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq, sql } from "@homarr/db";
|
||||
import {
|
||||
customWidgetDefinitions,
|
||||
customWidgetSecrets,
|
||||
legacyCustomWidgetDefinitions,
|
||||
legacyCustomWidgetSecrets,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
|
||||
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
configurePreviewSessionSource,
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
} from "../../custom-widget/preview-sessions";
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: ["admin"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
const nonAdminSession = {
|
||||
...session,
|
||||
user: { ...session.user, permissions: ["board-modify-all"] },
|
||||
} satisfies Session;
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
|
||||
if (!pokedex) throw new Error("Pokédex bundled widget is missing");
|
||||
const jellyfinDefaultSource = jellyfin.sources.default;
|
||||
if (!jellyfinDefaultSource || jellyfinDefaultSource.type === "integration")
|
||||
throw new Error("Jellyfin default source is missing");
|
||||
|
||||
function createCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
}
|
||||
|
||||
function createNonAdminCaller(db: ReturnType<typeof createDb>) {
|
||||
return customWidgetRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: nonAdminSession,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareDatabase() {
|
||||
const db = createDb();
|
||||
await db.insert(users).values({ id: userId });
|
||||
return db;
|
||||
}
|
||||
|
||||
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
|
||||
db.run(
|
||||
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_secret_insert
|
||||
BEFORE INSERT ON custom_widget_v2_secret
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced secret insert failure');
|
||||
END`),
|
||||
);
|
||||
}
|
||||
|
||||
function rejectDefinitionUpdates(db: ReturnType<typeof createDb>) {
|
||||
db.run(
|
||||
sql.raw(`CREATE TRIGGER reject_custom_widget_v2_definition_update
|
||||
BEFORE UPDATE ON custom_widget_v2_definition
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced definition update failure');
|
||||
END`),
|
||||
);
|
||||
}
|
||||
|
||||
async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
|
||||
const id = "legacy-jellyfin";
|
||||
const encryptedValue = "preserved.secret" as `${string}.${string}`;
|
||||
await db.insert(legacyCustomWidgetDefinitions).values({
|
||||
id,
|
||||
name: "Legacy Jellyfin",
|
||||
url: "http://jellyfin.local/Items/Counts",
|
||||
authType: "apiKeyHeader",
|
||||
headerName: "X-Emby-Token",
|
||||
method: "GET",
|
||||
displayType: "singleValue",
|
||||
enabled: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
await db.insert(legacyCustomWidgetSecrets).values({
|
||||
definitionId: id,
|
||||
kind: "apiKey",
|
||||
encryptedValue,
|
||||
updatedAt: new Date(0),
|
||||
});
|
||||
return { id, encryptedValue };
|
||||
}
|
||||
|
||||
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
|
||||
|
||||
describe("custom widget definition persistence", () => {
|
||||
test("creates and previews MCP-friendly multiline templateLines without losing the JSX", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const { template, ...definition } = jellyfin;
|
||||
const templateLines = template.split("\n");
|
||||
|
||||
const validation = await caller.validate({ widget: { ...definition, templateLines } });
|
||||
expect(validation).toMatchObject({
|
||||
valid: true,
|
||||
summary: {
|
||||
name: jellyfin.name,
|
||||
sourceIds: Object.keys(jellyfin.sources),
|
||||
requestIds: Object.keys(jellyfin.requests),
|
||||
templateLineCount: templateLines.length,
|
||||
},
|
||||
});
|
||||
expect(validation).not.toHaveProperty("widget");
|
||||
expect(validation).not.toHaveProperty("templateLines");
|
||||
expect(Buffer.byteLength(JSON.stringify(validation), "utf8")).toBeLessThan(2_000);
|
||||
const preview = await caller.previewCreate({ definition: { ...definition, templateLines }, secrets: [] });
|
||||
expect(preview).toMatchObject({
|
||||
success: true,
|
||||
previewPath: `/manage/custom-widgets/preview/${preview.previewSession.id}`,
|
||||
});
|
||||
expect(preview).not.toHaveProperty("definition");
|
||||
expect(preview.previewSession).not.toHaveProperty("secrets");
|
||||
expect(preview.previewSession).not.toHaveProperty("sources");
|
||||
expect(preview.previewSession).not.toHaveProperty("template");
|
||||
expect(Buffer.byteLength(JSON.stringify(preview), "utf8")).toBeLessThan(4_000);
|
||||
expect(preview.queries.map(({ requestId }) => requestId)).toEqual(Object.keys(jellyfin.requests));
|
||||
expect(preview.queries.every(({ nextStep }) => nextStep.includes("customWidget_previewQuery"))).toBe(true);
|
||||
|
||||
const created = await caller.create({ ...definition, templateLines, secrets: [] });
|
||||
expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
|
||||
expect(created.nextAction).toEqual({
|
||||
type: "place-custom-widget",
|
||||
widgetKind: "customApi",
|
||||
options: { definitionId: created.id },
|
||||
whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
|
||||
whenTargetIsUnknown:
|
||||
"Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
|
||||
});
|
||||
expect((await caller.get({ id: created.id })).template).toBe(template);
|
||||
});
|
||||
|
||||
test("persists the exact final tested Pokédex preview without resending its JSX", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const { template, ...definition } = pokedex;
|
||||
const candidate = { ...definition, templateLines: template.split("\n") };
|
||||
|
||||
// A long-running authoring request can iterate several times. Only the final preview session
|
||||
// is eligible for persistence, and its compact ID replaces a second large streamed payload.
|
||||
await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
const finalPreview = await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
const createInput = { previewSessionId: finalPreview.previewSession.id, targetBoardId: "board-hey" };
|
||||
expect(Buffer.byteLength(JSON.stringify(createInput), "utf8")).toBeLessThan(128);
|
||||
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
||||
|
||||
const previewSession = await getPreviewSession(finalPreview.previewSession.id, userId);
|
||||
for (const [requestId, request] of Object.entries(previewSession.requests)) {
|
||||
if (request.kind !== "query") continue;
|
||||
await appendPreviewJournal(previewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultSource = previewSession.sources.default;
|
||||
if (!defaultSource || defaultSource.type === "integration") throw new Error("Pokédex preview source is missing");
|
||||
await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []);
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
||||
|
||||
const finalPreviewSession = await getPreviewSession(finalPreview.previewSession.id, userId);
|
||||
for (const [requestId, request] of Object.entries(finalPreviewSession.requests)) {
|
||||
if (request.kind !== "query") continue;
|
||||
await appendPreviewJournal(finalPreviewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const created = await caller.createFromPreview(createInput);
|
||||
expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
|
||||
expect(created.nextAction.options).toEqual({ definitionId: created.id });
|
||||
expect(created.nextAction.targetBoardId).toBe("board-hey");
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({
|
||||
name: pokedex.name,
|
||||
description: pokedex.description,
|
||||
template,
|
||||
sources: pokedex.sources,
|
||||
requests: pokedex.requests,
|
||||
options: pokedex.options,
|
||||
});
|
||||
});
|
||||
|
||||
test("requires current-revision query and action evidence after a template-only preview revision", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const definition = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Request queue",
|
||||
sources: {
|
||||
default: { baseUrl: "https://example.com", networkScope: "public", auth: "none" },
|
||||
},
|
||||
requests: {
|
||||
queue: { path: "/requests" },
|
||||
approve: {
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/requests/{param:id}/approve",
|
||||
confirmation: "Approve this request?",
|
||||
invalidates: ["queue"],
|
||||
},
|
||||
},
|
||||
options: {},
|
||||
template:
|
||||
'<Stack><Text>{data.queue?.length ?? 0} requests</Text><ActionButton requestId="approve" params={{ id: 1 }}>Approve</ActionButton></Stack>',
|
||||
});
|
||||
const preview = await caller.previewCreate({ definition, secrets: [] });
|
||||
const createInput = { previewSessionId: preview.previewSession.id };
|
||||
const initialSession = await getPreviewSession(preview.previewSession.id, userId);
|
||||
await appendPreviewJournal(initialSession, {
|
||||
requestId: "queue",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/requests",
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow(
|
||||
"Test every final preview action before creating the widget: approve",
|
||||
);
|
||||
await appendPreviewJournal(initialSession, {
|
||||
requestId: "approve",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/requests/{param:id}/approve",
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
const revisedTemplate = definition.template.replace("requests</Text>", "pending requests</Text>");
|
||||
await caller.previewReviseTemplate({
|
||||
sessionId: initialSession.id,
|
||||
expectedRevision: initialSession.revision,
|
||||
template: revisedTemplate,
|
||||
});
|
||||
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow(
|
||||
"Test every final preview query successfully before creating the widget: queue",
|
||||
);
|
||||
const revisedSession = await getPreviewSession(preview.previewSession.id, userId);
|
||||
await appendPreviewJournal(revisedSession, {
|
||||
requestId: "queue",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/requests",
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
await appendPreviewJournal(revisedSession, {
|
||||
requestId: "approve",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/requests/{param:id}/approve",
|
||||
status: null,
|
||||
durationMs: 0,
|
||||
simulated: true,
|
||||
});
|
||||
|
||||
const created = await caller.createFromPreview(createInput);
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({ template: revisedTemplate });
|
||||
});
|
||||
|
||||
test("updates the existing widget from its exact tested edit preview and preserves inherited secrets", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const updatedTemplate = jellyfin.template.replace("Jellyfin library", "Updated Jellyfin library");
|
||||
const preview = await caller.previewCreate({
|
||||
definitionId: created.id,
|
||||
definition: { ...jellyfin, name: "Updated Jellyfin", template: updatedTemplate },
|
||||
secrets: [],
|
||||
});
|
||||
expect(preview.persistenceTool).toBe("customWidget_updateFromPreview");
|
||||
expect(preview.sourceConfigurations).toEqual([]);
|
||||
const previewSession = await getPreviewSession(preview.previewSession.id, userId);
|
||||
for (const [requestId, request] of Object.entries(previewSession.requests)) {
|
||||
if (request.kind !== "query") continue;
|
||||
await appendPreviewJournal(previewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await caller.updateFromPreview({ previewSessionId: previewSession.id });
|
||||
|
||||
expect(updated).toEqual({
|
||||
id: created.id,
|
||||
managementPath: `/manage/custom-widgets/edit/${created.id}`,
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(1);
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({
|
||||
name: "Updated Jellyfin",
|
||||
template: updatedTemplate,
|
||||
secrets: [{ sourceId: "default", kind: "apiKey", hasValue: true }],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects updateFromPreview when the preview is not associated with a stored definition", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const preview = await caller.previewCreate({ definition: pokedex, secrets: [] });
|
||||
|
||||
await expect(caller.updateFromPreview({ previewSessionId: preview.previewSession.id })).rejects.toThrow(
|
||||
"This preview is not associated with an existing custom widget definition",
|
||||
);
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rejects createFromPreview for an edit preview instead of duplicating the stored widget", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...pokedex, secrets: [] });
|
||||
const preview = await caller.previewCreate({
|
||||
definitionId: created.id,
|
||||
definition: { ...pokedex, name: "Edited Pokédex" },
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
await expect(caller.createFromPreview({ previewSessionId: preview.previewSession.id })).rejects.toThrow(
|
||||
"This edit preview must update its existing custom widget with customWidget_updateFromPreview",
|
||||
);
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(1);
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({ name: pokedex.name });
|
||||
});
|
||||
|
||||
test("requires current-revision evidence before updating from an edit preview", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...pokedex, secrets: [] });
|
||||
const preview = await caller.previewCreate({
|
||||
definitionId: created.id,
|
||||
definition: { ...pokedex, name: "Updated Pokédex" },
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
await expect(caller.updateFromPreview({ previewSessionId: preview.previewSession.id })).rejects.toThrow(
|
||||
"Test every final preview query successfully before updating the widget",
|
||||
);
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({ name: pokedex.name });
|
||||
});
|
||||
|
||||
test("rejects an edit preview after the stored credentials change", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const preview = await caller.previewCreate({
|
||||
definitionId: created.id,
|
||||
definition: { ...jellyfin, name: "Stale Jellyfin edit" },
|
||||
secrets: [],
|
||||
});
|
||||
const previewSession = await getPreviewSession(preview.previewSession.id, userId);
|
||||
await appendPreviewJournal(previewSession, {
|
||||
requestId: "counts",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/Items/Counts",
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
await caller.secretSet({
|
||||
definitionId: created.id,
|
||||
secret: { ...secret, value: "rotated-secret" },
|
||||
});
|
||||
const rotatedSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
|
||||
await expect(caller.updateFromPreview({ previewSessionId: preview.previewSession.id })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({ name: jellyfin.name });
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toEqual(rotatedSecret);
|
||||
});
|
||||
|
||||
test("retains persistence evidence for every request beyond the bounded journal", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const requests = Object.fromEntries(
|
||||
Array.from({ length: 64 }, (_, index) => [`query-${index}`, { path: `/queries/${index}` }]),
|
||||
);
|
||||
const definition = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Large request set",
|
||||
sources: {
|
||||
default: { baseUrl: "https://example.com", networkScope: "public", auth: "none" },
|
||||
},
|
||||
requests,
|
||||
options: {},
|
||||
template: "<Text>All requests tested</Text>",
|
||||
});
|
||||
const preview = await caller.previewCreate({ definition, secrets: [] });
|
||||
const previewSession = await getPreviewSession(preview.previewSession.id, userId);
|
||||
|
||||
for (const [requestId, request] of Object.entries(previewSession.requests)) {
|
||||
await appendPreviewJournal(previewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(await getPreviewJournal(previewSession.id, userId)).toHaveLength(50);
|
||||
await expect(caller.createFromPreview({ previewSessionId: previewSession.id })).resolves.toMatchObject({
|
||||
managementPath: expect.stringContaining("/manage/custom-widgets/edit/"),
|
||||
});
|
||||
});
|
||||
|
||||
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
const updatedTemplate = jellyfin.template.replace("Jellyfin library", "Updated Jellyfin library");
|
||||
|
||||
const updated = await caller.update({ id: created.id, templateLines: updatedTemplate.split("\n") });
|
||||
|
||||
expect(updated.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
|
||||
expect((await caller.get({ id: created.id })).template).toBe(updatedTemplate);
|
||||
});
|
||||
|
||||
test("reports joined templateLines validation issues as BAD_REQUEST for create and update", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const { template: _template, ...definition } = jellyfin;
|
||||
const invalidTemplateLines = ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"];
|
||||
|
||||
const createError = await caller
|
||||
.create({ ...definition, templateLines: invalidTemplateLines, secrets: [] })
|
||||
.catch((error: unknown) => error);
|
||||
expect(createError).toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
const updateError = await caller
|
||||
.update({ id: created.id, templateLines: invalidTemplateLines })
|
||||
.catch((error: unknown) => error);
|
||||
expect(updateError).toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
|
||||
});
|
||||
expect((await caller.get({ id: created.id })).template).toBe(jellyfin.template);
|
||||
});
|
||||
|
||||
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const legacy = await insertLegacyJellyfin(db);
|
||||
|
||||
await createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] });
|
||||
|
||||
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({
|
||||
id: legacy.id,
|
||||
name: "Legacy Jellyfin",
|
||||
enabled: false,
|
||||
});
|
||||
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findFirst()).toMatchObject({
|
||||
id: legacy.id,
|
||||
enabled: false,
|
||||
creatorId: userId,
|
||||
});
|
||||
expect(await db.query.customWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
sourceId: "default",
|
||||
kind: "apiKey",
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
|
||||
const migratedList = await createCaller(db).list();
|
||||
expect(migratedList).toHaveLength(1);
|
||||
expect(migratedList[0]).toMatchObject({ id: legacy.id, migrationRequired: false });
|
||||
|
||||
await createCaller(db).delete({ id: legacy.id });
|
||||
|
||||
const fallbackList = await createCaller(db).list();
|
||||
expect(fallbackList).toHaveLength(1);
|
||||
expect(fallbackList[0]).toMatchObject({ id: legacy.id, migrationRequired: true });
|
||||
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the legacy widget recoverable when confirmed replacement fails", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const legacy = await insertLegacyJellyfin(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(createCaller(db).migrateLegacy({ id: legacy.id, widget: jellyfin, secrets: [] })).rejects.toThrow(
|
||||
"forced secret insert failure",
|
||||
);
|
||||
|
||||
expect(await db.query.legacyCustomWidgetDefinitions.findFirst()).toMatchObject({ id: legacy.id });
|
||||
expect(await db.query.legacyCustomWidgetSecrets.findFirst()).toMatchObject({
|
||||
definitionId: legacy.id,
|
||||
encryptedValue: legacy.encryptedValue,
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back create when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.create({ ...jellyfin, secrets: [secret] })).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rolls back import when its secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(caller.import({ widget: jellyfin, secrets: [secret] })).rejects.toThrow(
|
||||
"forced secret insert failure",
|
||||
);
|
||||
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("refuses to export a stored definition containing embedded credentials", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||
const counts = definition.requests.counts;
|
||||
if (!counts) throw new Error("Jellyfin counts request is missing");
|
||||
const id = createId();
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id,
|
||||
...serializeCustomWidgetDefinition({
|
||||
...definition,
|
||||
requests: {
|
||||
...definition.requests,
|
||||
counts: {
|
||||
...counts,
|
||||
headers: { ...counts.headers, "X-Auth": "Bearer sk-secret-123456" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
await expect(createCaller(db).export({ id })).rejects.toThrow("Credentials must use source authentication");
|
||||
});
|
||||
|
||||
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const originalSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.update({
|
||||
id: created.id,
|
||||
name: "Changed Jellyfin Library",
|
||||
secrets: [{ ...secret, value: "replacement-secret" }],
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
const storedDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const storedSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
expect(storedDefinition?.name).toBe(originalDefinition?.name);
|
||||
expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt);
|
||||
expect(storedSecret).toEqual(originalSecret);
|
||||
});
|
||||
|
||||
test("keeps the existing secret when setting its replacement fails", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const originalSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.secretSet({
|
||||
definitionId: created.id,
|
||||
secret: { ...secret, value: "replacement-secret" },
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
}),
|
||||
).toEqual(originalDefinition);
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toEqual(originalSecret);
|
||||
});
|
||||
|
||||
test("keeps the existing secret when clearing it cannot update the definition revision", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
});
|
||||
const originalSecret = await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
});
|
||||
rejectDefinitionUpdates(db);
|
||||
|
||||
await expect(
|
||||
caller.secretClear({
|
||||
definitionId: created.id,
|
||||
sourceId: secret.sourceId,
|
||||
kind: secret.kind,
|
||||
}),
|
||||
).rejects.toThrow("forced definition update failure");
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetDefinitions.findFirst({
|
||||
where: eq(customWidgetDefinitions.id, created.id),
|
||||
}),
|
||||
).toEqual(originalDefinition);
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findFirst({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toEqual(originalSecret);
|
||||
});
|
||||
|
||||
test("configures a source URL and credential together", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
|
||||
await caller.sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "http://jellyfin.local:8096",
|
||||
networkScope: "private",
|
||||
secrets: [secret],
|
||||
});
|
||||
|
||||
const definition = await caller.get({ id: created.id });
|
||||
expect(definition.sources.default?.baseUrl).toBe("http://jellyfin.local:8096");
|
||||
expect(definition.secrets).toMatchObject([{ sourceId: "default", kind: "apiKey", hasValue: true }]);
|
||||
});
|
||||
|
||||
test("rolls back a source URL when its credential cannot be stored", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
const before = await caller.get({ id: created.id });
|
||||
rejectSecretInserts(db);
|
||||
|
||||
await expect(
|
||||
caller.sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "http://jellyfin.local:8096",
|
||||
networkScope: "private",
|
||||
secrets: [secret],
|
||||
}),
|
||||
).rejects.toThrow("forced secret insert failure");
|
||||
|
||||
expect((await caller.get({ id: created.id })).sources.default?.baseUrl).toBe(before.sources.default?.baseUrl);
|
||||
});
|
||||
|
||||
test("uses the preview widget name in a user configuration request", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const preview = await caller.previewCreate({
|
||||
definition: { ...jellyfin, name: "Living room Jellyfin" },
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
const request = await caller.configurationRequestUser({
|
||||
previewSessionId: preview.previewSession.id,
|
||||
sourceId: "default",
|
||||
});
|
||||
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.requestId, userId)).toMatchObject({
|
||||
widgetName: "Living room Jellyfin",
|
||||
});
|
||||
});
|
||||
|
||||
test("collects a preview URL and scope without credentials for an unauthenticated source", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const definition = {
|
||||
...jellyfin,
|
||||
sources: {
|
||||
...jellyfin.sources,
|
||||
default: { ...jellyfinDefaultSource, baseUrl: "https://your-service.example.com", auth: "none" as const },
|
||||
},
|
||||
};
|
||||
const preview = await caller.previewCreate({ definition, secrets: [] });
|
||||
const request = await caller.configurationRequestUser({
|
||||
previewSessionId: preview.previewSession.id,
|
||||
sourceId: "default",
|
||||
});
|
||||
|
||||
expect(request).toMatchObject({
|
||||
previewSessionId: preview.previewSession.id,
|
||||
sourceId: "default",
|
||||
status: "pending",
|
||||
});
|
||||
expect(await getCustomWidgetConfigurationRequestForUser(request.requestId, userId)).toMatchObject({ kinds: [] });
|
||||
await configurePreviewSessionSource(
|
||||
preview.previewSession.id,
|
||||
userId,
|
||||
"default",
|
||||
{ ...definition.sources.default, baseUrl: "http://dispatcharr.local:9191", networkScope: "private" },
|
||||
[],
|
||||
);
|
||||
await expect(getPreviewSession(preview.previewSession.id, userId)).resolves.toMatchObject({
|
||||
sources: { default: { baseUrl: "http://dispatcharr.local:9191", networkScope: "private", auth: "none" } },
|
||||
secrets: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not inherit a stored credential after a preview source binding changes", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
const changedDefinition = {
|
||||
...jellyfin,
|
||||
sources: {
|
||||
...jellyfin.sources,
|
||||
default: { ...jellyfinDefaultSource, baseUrl: "https://attacker.example" },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
createNonAdminCaller(db).previewCreate({
|
||||
definitionId: created.id,
|
||||
definition: changedDefinition,
|
||||
secrets: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
caller.previewCreate({ definitionId: created.id, definition: changedDefinition, secrets: [secret] }),
|
||||
).resolves.toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
test("clears persisted credentials when an admin source edit changes its binding", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
|
||||
|
||||
await createCaller(db).sourceConfigure({
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "https://other.example/api",
|
||||
networkScope: "public",
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("uses the persisted source binding from inside the configuration transaction", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [secret] });
|
||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||
const defaultSource = definition.sources.default;
|
||||
if (!defaultSource || defaultSource.type === "integration") throw new Error("Jellyfin default source is missing");
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set(
|
||||
serializeCustomWidgetDefinition({
|
||||
...definition,
|
||||
name: "Concurrent definition edit",
|
||||
sources: {
|
||||
...definition.sources,
|
||||
default: { ...defaultSource, baseUrl: "https://changed.example/api" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
.where(eq(customWidgetDefinitions.id, created.id));
|
||||
|
||||
await expect(
|
||||
configureCustomWidgetSourceFromRequest(db, {
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: defaultSource.baseUrl,
|
||||
networkScope: defaultSource.networkScope,
|
||||
secrets: [],
|
||||
expectedSource: defaultSource,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "configured",
|
||||
source: { baseUrl: defaultSource.baseUrl },
|
||||
});
|
||||
|
||||
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
|
||||
name: "Concurrent definition edit",
|
||||
sources: { default: { baseUrl: defaultSource.baseUrl } },
|
||||
});
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rejects stale setup-link credentials after a concurrent authentication binding change", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
|
||||
const definition = customWidgetDefinitionSchema.parse(jellyfin);
|
||||
const expectedSource = definition.sources.default;
|
||||
if (!expectedSource || expectedSource.type === "integration" || typeof expectedSource.auth === "string") {
|
||||
throw new Error("Jellyfin default source must use header authentication");
|
||||
}
|
||||
await db
|
||||
.update(customWidgetDefinitions)
|
||||
.set(
|
||||
serializeCustomWidgetDefinition({
|
||||
...definition,
|
||||
name: "Concurrent definition edit",
|
||||
sources: {
|
||||
...definition.sources,
|
||||
default: {
|
||||
...expectedSource,
|
||||
auth: { ...expectedSource.auth, name: "X-Replaced-Credential" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.where(eq(customWidgetDefinitions.id, created.id));
|
||||
|
||||
await expect(
|
||||
configureCustomWidgetSourceFromRequest(db, {
|
||||
definitionId: created.id,
|
||||
sourceId: "default",
|
||||
baseUrl: "https://configured.example/api",
|
||||
networkScope: "public",
|
||||
secrets: [secret],
|
||||
expectedSource,
|
||||
}),
|
||||
).resolves.toEqual({ status: "binding-changed" });
|
||||
|
||||
expect(await createCaller(db).get({ id: created.id })).toMatchObject({
|
||||
name: "Concurrent definition edit",
|
||||
sources: {
|
||||
default: {
|
||||
baseUrl: expectedSource.baseUrl,
|
||||
auth: { name: "X-Replaced-Credential" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("clears persisted credentials when a general definition update changes its binding", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [secret] });
|
||||
|
||||
await createCaller(db).update({
|
||||
id: created.id,
|
||||
sources: {
|
||||
...jellyfin.sources,
|
||||
default: { ...jellyfinDefaultSource, baseUrl: "https://other.example/api" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await db.query.customWidgetSecrets.findMany({
|
||||
where: eq(customWidgetSecrets.definitionId, created.id),
|
||||
}),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("requires an administrator to create a stored-definition setup link", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const created = await createCaller(db).create({ ...jellyfin, secrets: [] });
|
||||
|
||||
await expect(
|
||||
createNonAdminCaller(db).configurationRequestUser({ definitionId: created.id, sourceId: "default" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
test("allows only one concurrent template patch for a revision", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, template: "<Text>Original</Text>", secrets: [] });
|
||||
const read = await caller.readTemplate({ id: created.id });
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
caller.templatePatch({
|
||||
id: created.id,
|
||||
expectedRevision: read.revision,
|
||||
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>First</Text>"] }],
|
||||
}),
|
||||
caller.templatePatch({
|
||||
id: created.id,
|
||||
expectedRevision: read.revision,
|
||||
edits: [{ startLine: 1, deleteCount: 1, replacementLines: ["<Text>Second</Text>"] }],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
|
||||
import { buildLegacyCustomWidgetMigrationPrompt } from "../../custom-widget/legacy-migration";
|
||||
|
||||
describe("legacy custom widget migration prompt", () => {
|
||||
test("preserves ordinary request values while redacting credential-like values", () => {
|
||||
const prompt = buildLegacyCustomWidgetMigrationPrompt(
|
||||
{
|
||||
id: "legacy",
|
||||
name: "Private service",
|
||||
description: "Status",
|
||||
iconUrl: "https://icons:password@example.test/private/icon.svg?access_token=icon-secret",
|
||||
url: "https://admin:password@example.test/api/v1/path-token/status?api_key=top-secret&mode=full&page=2",
|
||||
authType: "bearer",
|
||||
headerName: null,
|
||||
method: "POST",
|
||||
requestBody: JSON.stringify({ token: "body-secret", limit: 5, label: "CPU" }),
|
||||
displayType: "singleValue",
|
||||
displayConfig: "corrupted-config-with-display-secret",
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
creatorId: null,
|
||||
},
|
||||
["apiKey"],
|
||||
);
|
||||
|
||||
expect(prompt).toContain("homarr-custom-widget-v1");
|
||||
expect(prompt).toContain("homarr-custom-widget-v2");
|
||||
expect(prompt).toContain('"path": "/api/v1/[REDACTED]/status"');
|
||||
expect(prompt).toContain('"mode": "full"');
|
||||
expect(prompt).toContain('"page": "2"');
|
||||
expect(prompt).toContain("https://example.test/private/icon.svg");
|
||||
expect(prompt).toContain('"limit": 5');
|
||||
expect(prompt).toContain('"label": "CPU"');
|
||||
expect(prompt).toContain("display configuration is unavailable");
|
||||
expect(prompt).not.toContain("top-secret");
|
||||
expect(prompt).not.toContain("body-secret");
|
||||
expect(prompt).not.toContain("admin:password");
|
||||
expect(prompt).not.toContain("path-token");
|
||||
expect(prompt).not.toContain("icon-secret");
|
||||
expect(prompt).not.toContain("icons:password");
|
||||
expect(prompt).not.toContain("display-secret");
|
||||
expect(prompt).not.toContain('"requestBody"');
|
||||
});
|
||||
|
||||
test("redacts a custom api-key query value without losing its parameter name", () => {
|
||||
const prompt = buildLegacyCustomWidgetMigrationPrompt(
|
||||
{
|
||||
id: "legacy-api-key-query",
|
||||
name: "Query-key service",
|
||||
description: "Status",
|
||||
iconUrl: null,
|
||||
url: "https://example.test/api/status?sig=custom-query-secret&mode=full",
|
||||
authType: "apiKeyQuery",
|
||||
headerName: "sig",
|
||||
method: "GET",
|
||||
requestBody: null,
|
||||
displayType: "singleValue",
|
||||
displayConfig: "{}",
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
creatorId: null,
|
||||
},
|
||||
["apiKey"],
|
||||
);
|
||||
|
||||
expect(prompt).toContain('"sig": "[REDACTED]"');
|
||||
expect(prompt).toContain('"mode": "full"');
|
||||
expect(prompt).toContain('"headerName": "sig"');
|
||||
expect(prompt).not.toContain("custom-query-secret");
|
||||
});
|
||||
|
||||
test("preserves valid JSX display configuration while redacting literals", () => {
|
||||
const prompt = buildLegacyCustomWidgetMigrationPrompt(
|
||||
{
|
||||
id: "legacy-custom-jsx",
|
||||
name: "Private service",
|
||||
description: "Status",
|
||||
iconUrl: null,
|
||||
url: "https://example.test/api/status",
|
||||
authType: "bearer",
|
||||
headerName: null,
|
||||
method: "GET",
|
||||
requestBody: null,
|
||||
displayType: "customJsx",
|
||||
displayConfig: stringifySuperJson({
|
||||
type: "customJsx",
|
||||
template: "<Text>LEGACY_PRIVATE_MARKER Bearer sk-secret-123456</Text>",
|
||||
}),
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
creatorId: null,
|
||||
},
|
||||
["apiKey"],
|
||||
);
|
||||
|
||||
expect(prompt).toContain("LEGACY_PRIVATE_MARKER");
|
||||
expect(prompt).not.toContain("Bearer sk-secret-123456");
|
||||
expect(prompt).not.toContain("sk-secret-123456");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { configurePreviewSessionSource } from "../../custom-widget/preview-sessions";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeRequest: vi.fn(async (_input: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
data: { fixtures: [{ id: "match-1", home_team: { name: "PSG" }, away_team: { name: "Lyon" } }] },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-executor", () => ({
|
||||
executeCustomWidgetRequest: mocks.executeRequest,
|
||||
invalidateCustomWidgetResponseCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
|
||||
describe("custom widget agent preview workflow", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("returns a stable normalized template digest from focused validation", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
const first = await caller.validateTemplate({ template: "<Text>Ready</Text>" });
|
||||
const equivalent = await caller.validateTemplate({ template: "<Text>Ready</Text>\u200B" });
|
||||
const different = await caller.validateTemplate({ template: "<Text>Waiting</Text>" });
|
||||
|
||||
expect(first).toMatchObject({
|
||||
valid: true,
|
||||
templateDigest: expect.stringMatching(/^tpl-[a-f0-9]{16}$/u),
|
||||
validationId: expect.stringMatching(/^template:tpl-[a-f0-9]{16}$/u),
|
||||
});
|
||||
expect(equivalent.templateDigest).toBe(first.templateDigest);
|
||||
expect(different.templateDigest).not.toBe(first.templateDigest);
|
||||
});
|
||||
|
||||
test("runs a named preview query and returns the real response shape to the agent", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
requests: { fixtures: { path: "/fixtures", query: { season: 2026 } } },
|
||||
templateLines: template.split("\n"),
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(preview.queries).toEqual([
|
||||
expect.objectContaining({ requestId: "fixtures", trigger: "load", parameterNames: [] }),
|
||||
]);
|
||||
expect(preview.sourceConfigurations).toEqual([
|
||||
expect.objectContaining({ sourceId: "default", nextStep: expect.stringContaining("before testing") }),
|
||||
]);
|
||||
const defaultSource = definition.sources.default;
|
||||
if (!defaultSource || defaultSource.type === "integration") throw new Error("Starter HTTP source is missing");
|
||||
await configurePreviewSessionSource(
|
||||
preview.previewSession.id,
|
||||
userId,
|
||||
"default",
|
||||
{ ...defaultSource, baseUrl: "https://sports.test", auth: defaultSource.auth ?? "none" },
|
||||
[],
|
||||
);
|
||||
await expect(
|
||||
caller.previewQuery({ sessionId: preview.previewSession.id, requestId: "fixtures", params: {} }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { fixtures: [expect.objectContaining({ id: "match-1" })] },
|
||||
}),
|
||||
);
|
||||
const request = mocks.executeRequest.mock.calls[0]?.[0] as { targetUrl: string | URL; kind: string } | undefined;
|
||||
expect(String(request?.targetUrl)).toBe("https://sports.test/fixtures?season=2026");
|
||||
expect(request?.kind).toBe("query");
|
||||
});
|
||||
|
||||
test("does not request source configuration for a user-supplied HTTP URL", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const defaultSource = definition.sources.default;
|
||||
if (!defaultSource || defaultSource.type === "integration") throw new Error("Starter HTTP source is missing");
|
||||
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
sources: {
|
||||
default: {
|
||||
...defaultSource,
|
||||
baseUrl: "https://dispatcharr.internal.test",
|
||||
},
|
||||
},
|
||||
templateLines: template.split("\n"),
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(preview.sourceConfigurations).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not request configuration for the required but unused static-widget source", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
sources: {
|
||||
default: { baseUrl: "https://example.com", networkScope: "public", auth: "bearer" },
|
||||
},
|
||||
requests: {},
|
||||
templateLines: ["<Text>Static dashboard note</Text>"],
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(preview.sourceConfigurations).toEqual([]);
|
||||
expect(preview.queries).toEqual([]);
|
||||
expect(preview.actions).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not request unused source credentials when every request disables authentication", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
sources: {
|
||||
default: {
|
||||
baseUrl: "https://public-endpoint.test",
|
||||
networkScope: "public",
|
||||
auth: { type: "apiKeyHeader", name: "X-API-Key" },
|
||||
},
|
||||
},
|
||||
requests: { publicStatus: { path: "/status", auth: "none" } },
|
||||
templateLines: template.split("\n"),
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(preview.sourceConfigurations).toEqual([]);
|
||||
await expect(
|
||||
caller.previewQuery({ sessionId: preview.previewSession.id, requestId: "publicStatus", params: {} }),
|
||||
).resolves.toMatchObject({ ok: true, sourceId: "default" });
|
||||
});
|
||||
|
||||
test("requires secure configuration before simulating an authenticated action-only preview", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
sources: {
|
||||
default: {
|
||||
baseUrl: "https://dispatcharr.internal.test",
|
||||
networkScope: "private",
|
||||
auth: { type: "apiKeyHeader", name: "X-API-Key" },
|
||||
},
|
||||
},
|
||||
requests: {
|
||||
restart: {
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/api/admin/restart",
|
||||
confirmation: "Restart Dispatcharr?",
|
||||
permission: "full",
|
||||
},
|
||||
},
|
||||
templateLines: ['<ActionButton requestId="restart" color="red">Restart Dispatcharr</ActionButton>'],
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(preview.queries).toEqual([]);
|
||||
expect(preview.actions).toEqual([expect.objectContaining({ requestId: "restart", method: "POST" })]);
|
||||
expect(preview.sourceConfigurations).toEqual([
|
||||
expect.objectContaining({ sourceId: "default", nextStep: expect.stringContaining("before testing") }),
|
||||
]);
|
||||
await expect(
|
||||
caller.previewAction({ sessionId: preview.previewSession.id, requestId: "restart", params: {} }),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: preview.previewSession.id,
|
||||
requestId: "restart",
|
||||
sourceId: "default",
|
||||
ok: false,
|
||||
error: expect.stringContaining("credentials are missing"),
|
||||
requiredNextTool: "customWidget_configurationRequestUser",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("addresses each blocked preview request to its exact source", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
sources: {
|
||||
default: { baseUrl: "https://primary.test", networkScope: "public", auth: "bearer" },
|
||||
secondary: { baseUrl: "https://secondary.test", networkScope: "public", auth: "basic" },
|
||||
},
|
||||
requests: {
|
||||
status: { source: "default", path: "/status" },
|
||||
restart: {
|
||||
source: "secondary",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/restart",
|
||||
confirmation: "Restart?",
|
||||
permission: "full",
|
||||
},
|
||||
},
|
||||
templateLines: [
|
||||
'<Stack><Text>{data.status?.state}</Text><ActionButton requestId="restart">Restart</ActionButton></Stack>',
|
||||
],
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(preview.sourceConfigurations).toEqual([
|
||||
expect.objectContaining({ sourceId: "default" }),
|
||||
expect.objectContaining({ sourceId: "secondary" }),
|
||||
]);
|
||||
await expect(
|
||||
caller.previewQuery({ sessionId: preview.previewSession.id, requestId: "status", params: {} }),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: preview.previewSession.id,
|
||||
requestId: "status",
|
||||
sourceId: "default",
|
||||
ok: false,
|
||||
requiredNextTool: "customWidget_configurationRequestUser",
|
||||
});
|
||||
await expect(
|
||||
caller.previewAction({ sessionId: preview.previewSession.id, requestId: "restart", params: {} }),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: preview.previewSession.id,
|
||||
requestId: "restart",
|
||||
sourceId: "secondary",
|
||||
ok: false,
|
||||
requiredNextTool: "customWidget_configurationRequestUser",
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports joined preview templateLines validation issues as BAD_REQUEST", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
|
||||
const error = await caller
|
||||
.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
templateLines: ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"],
|
||||
},
|
||||
secrets: [],
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("revises a preview template without resending or changing its manifest", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
requests: { fixtures: { path: "/fixtures" } },
|
||||
templateLines: template.split("\n"),
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
const revisedTemplate = "<Stack><Text>{data.fixtures?.name}</Text><Badge>Ready</Badge></Stack>";
|
||||
|
||||
const revised = await caller.previewReviseTemplate({
|
||||
sessionId: preview.previewSession.id,
|
||||
expectedRevision: 0,
|
||||
templateLines: revisedTemplate.split("\n"),
|
||||
});
|
||||
|
||||
expect(revised).toMatchObject({
|
||||
success: true,
|
||||
evidenceReset: true,
|
||||
previewSession: { id: preview.previewSession.id, revision: 1 },
|
||||
queries: [expect.objectContaining({ requestId: "fixtures" })],
|
||||
});
|
||||
expect(revised).not.toHaveProperty("definition");
|
||||
expect(revised).not.toHaveProperty("template");
|
||||
await expect(caller.previewGet({ sessionId: preview.previewSession.id })).resolves.toMatchObject({
|
||||
revision: 1,
|
||||
template: revisedTemplate,
|
||||
requests: [expect.objectContaining({ id: "fixtures" })],
|
||||
});
|
||||
await expect(
|
||||
caller.previewReviseTemplate({
|
||||
sessionId: preview.previewSession.id,
|
||||
expectedRevision: 1,
|
||||
templateLines: ['<ActionButton requestId="missing">Run</ActionButton>'],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
|
||||
import { resolveOptionRequestValues } from "../../custom-widget/management-queries";
|
||||
|
||||
const loadRequest: CustomJsxRequest = {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/endpoints/{option:environmentId}/containers",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
};
|
||||
|
||||
describe("preview request option bindings", () => {
|
||||
it("resolves load request parameters from the selected preview options", () => {
|
||||
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
|
||||
"option:environmentId": 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves invoking component parameters for manual requests", () => {
|
||||
const manualRequest = {
|
||||
...loadRequest,
|
||||
trigger: "manual" as const,
|
||||
path: "/endpoints/{param:endpointId}/containers",
|
||||
};
|
||||
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
|
||||
endpointId: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration option request bindings", () => {
|
||||
it("resolves option configuration and runtime parameters in their own slots", () => {
|
||||
const request: CustomJsxRequest = {
|
||||
...loadRequest,
|
||||
path: "/environments/{option:environmentId}/containers/{param:containerId}",
|
||||
};
|
||||
|
||||
expect(resolveOptionRequestValues(request, { environmentId: 7, containerId: 12, unrelated: true })).toEqual({
|
||||
"option:environmentId": 7,
|
||||
containerId: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
assertJsonBudget,
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "../../custom-widget/request-executor";
|
||||
|
||||
describe("custom widget request executor", () => {
|
||||
test.each([
|
||||
["8.8.8.8", "public"],
|
||||
["10.0.0.1", "private"],
|
||||
["127.0.0.1", "loopback"],
|
||||
["169.254.169.254", "blocked"],
|
||||
["224.0.0.1", "blocked"],
|
||||
["::ffff:127.0.0.1", "blocked"],
|
||||
["fe80::1", "blocked"],
|
||||
] as const)("classifies %s as %s", (address, expected) => {
|
||||
expect(classifyAddress(address)).toBe(expected);
|
||||
});
|
||||
|
||||
test("enforces configured address scopes", async () => {
|
||||
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
|
||||
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
|
||||
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
test("allows only credential-free HTTP(S) URLs without fragments", () => {
|
||||
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
|
||||
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("Only HTTP and HTTPS");
|
||||
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
|
||||
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
|
||||
});
|
||||
|
||||
test("rejects cross-origin named request targets", () => {
|
||||
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
|
||||
expect(() => resolveSameOriginTarget("https://example.com/base", "https://attacker.example/status")).toThrow(
|
||||
"origin",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["Authorization", "Cookie", "Host", "Proxy-Authorization", "Sec-Fetch-Site", "X-Forwarded-For"])(
|
||||
"rejects the reserved %s header",
|
||||
(header) => {
|
||||
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects response JSON that exceeds the depth budget", () => {
|
||||
let nested: Record<string, unknown> = {};
|
||||
const root = nested;
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
nested.child = {};
|
||||
nested = nested.child as Record<string, unknown>;
|
||||
}
|
||||
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
import { acquireCustomWidgetRequestLimit } from "../../custom-widget/request-limits";
|
||||
|
||||
test("enforces the server-side DELETE rate limit with retry metadata", async () => {
|
||||
const input = { category: "delete" as const, userId: createId(), itemId: createId(), definitionId: createId() };
|
||||
for (let count = 0; count < 3; count += 1) {
|
||||
const release = await acquireCustomWidgetRequestLimit(input);
|
||||
await release();
|
||||
}
|
||||
|
||||
try {
|
||||
await acquireCustomWidgetRequestLimit(input);
|
||||
expect.unreachable("the fourth DELETE request should be rate limited");
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
||||
const cause = (error as { cause?: { flatten?: () => Record<string, unknown> } }).cause;
|
||||
expect(cause?.flatten?.()).toMatchObject({ retryAfterMs: expect.any(Number) });
|
||||
}
|
||||
});
|
||||
|
||||
test("enforces four concurrent requests per user and item", async () => {
|
||||
const input = { category: "query" as const, userId: createId(), itemId: createId(), definitionId: createId() };
|
||||
const releases = await Promise.all(Array.from({ length: 4 }, () => acquireCustomWidgetRequestLimit(input)));
|
||||
|
||||
await expect(acquireCustomWidgetRequestLimit(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
||||
await Promise.all(releases.map((release) => release()));
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
BUNDLED_CUSTOM_WIDGETS,
|
||||
CUSTOM_WIDGET_STARTER,
|
||||
customWidgetDefinitionSchema,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import {
|
||||
safeParseStoredCustomWidgetDefinition,
|
||||
serializeCustomWidgetDefinition,
|
||||
} from "../../custom-widget/stored-definition";
|
||||
|
||||
describe("stored custom widget definitions", () => {
|
||||
test("parses a valid stored definition", () => {
|
||||
const result = safeParseStoredCustomWidgetDefinition(
|
||||
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
|
||||
});
|
||||
|
||||
test("returns useful issues for an invalid stored request instead of throwing", () => {
|
||||
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
|
||||
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
|
||||
|
||||
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(currency.widget));
|
||||
const result = safeParseStoredCustomWidgetDefinition({
|
||||
...stored,
|
||||
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
|
||||
});
|
||||
});
|
||||
|
||||
test("contains malformed serialized data", () => {
|
||||
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
|
||||
|
||||
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
|
||||
success: false,
|
||||
issues: [{ message: "Stored widget data could not be read" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(async (_id: string): Promise<unknown> => undefined),
|
||||
list: vi.fn(async (_options: unknown): Promise<unknown> => undefined),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => mocks.logger,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/workshop/backend", () => ({
|
||||
WorkshopBackend: class {
|
||||
public get(id: string) {
|
||||
return mocks.get(id);
|
||||
}
|
||||
|
||||
public list(options: unknown) {
|
||||
return mocks.list(options);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
|
||||
const session = {
|
||||
user: {
|
||||
id: createId(),
|
||||
permissions: ["admin"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createCaller = () =>
|
||||
customWidgetRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
|
||||
const createTokenizedFailure = (apiKey: string) => {
|
||||
const tokenizedUrl = `https://workshop.example/api/submissions?access_token=${apiKey}`;
|
||||
const error = new Error(`Workshop request failed for ${tokenizedUrl}`);
|
||||
error.name = `WorkshopTransportError:${tokenizedUrl}`;
|
||||
return error;
|
||||
};
|
||||
|
||||
const expectSafeFailure = (
|
||||
error: unknown,
|
||||
apiKey: string,
|
||||
logMessage: string,
|
||||
event: "workshop_widget_lookup_failed" | "workshop_widget_search_failed",
|
||||
) => {
|
||||
expect(error).toMatchObject({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "Workshop is unavailable",
|
||||
});
|
||||
expect((error as Error).cause).toBeUndefined();
|
||||
expect(String(error)).not.toContain(apiKey);
|
||||
expect(String(error)).not.toContain("access_token");
|
||||
expect(mocks.logger.error).toHaveBeenCalledWith(logMessage, {
|
||||
event,
|
||||
errorName: "WorkshopBackendError",
|
||||
});
|
||||
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(apiKey);
|
||||
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain("access_token");
|
||||
};
|
||||
|
||||
describe("Workshop backend error privacy", () => {
|
||||
beforeEach(() => {
|
||||
mocks.get.mockReset();
|
||||
mocks.list.mockReset();
|
||||
mocks.logger.error.mockClear();
|
||||
});
|
||||
|
||||
test("redacts backend lookup failures from TRPC output and logs", async () => {
|
||||
const apiKey = "workshop-lookup-secret";
|
||||
mocks.get.mockRejectedValueOnce(createTokenizedFailure(apiKey));
|
||||
|
||||
const error = await createCaller()
|
||||
.workshopGet({ submissionId: "submission-id" })
|
||||
.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expectSafeFailure(error, apiKey, "Workshop widget lookup failed", "workshop_widget_lookup_failed");
|
||||
});
|
||||
|
||||
test("redacts backend search failures from TRPC output and logs", async () => {
|
||||
const apiKey = "workshop-search-secret";
|
||||
mocks.list.mockRejectedValueOnce(createTokenizedFailure(apiKey));
|
||||
|
||||
const error = await createCaller()
|
||||
.workshopSearch({ query: "weather" })
|
||||
.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expectSafeFailure(error, apiKey, "Workshop widget search failed", "workshop_widget_search_failed");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user