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
@@ -33,7 +33,7 @@ describe("all should return all apps", () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
|
||||
await db.insert(apps).values([
|
||||
@@ -74,10 +74,49 @@ describe("all should return all apps", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
// Arrange
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.all();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaginated should require board modification access", () => {
|
||||
test("should throw FORBIDDEN without board-modify-all", async () => {
|
||||
describe("getPaginated should return apps to authenticated users", () => {
|
||||
test("should throw UNAUTHORIZED without a session", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("should return apps with app-modify-all permission", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(["app-modify-all"]),
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
|
||||
totalCount: 1,
|
||||
items: [{ id: "1" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN without app-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
@@ -86,8 +125,20 @@ describe("getPaginated should require board modification access", () => {
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all", async () => {
|
||||
describe("search should require app-modify-all or board-modify-all permission", () => {
|
||||
test("should throw FORBIDDEN without app-modify-all or board-modify-all permission", async () => {
|
||||
const caller = appRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createDefaultSession(),
|
||||
});
|
||||
|
||||
await expect(caller.search({ query: "Homarr" })).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("should return apps with board-modify-all permission", async () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
@@ -96,10 +147,10 @@ describe("getPaginated should require board modification access", () => {
|
||||
});
|
||||
await db.insert(apps).values({ id: "1", name: "Homarr", iconUrl: "https://homarr.dev/icon.svg" });
|
||||
|
||||
await expect(caller.getPaginated({ page: 1, pageSize: 10 })).resolves.toMatchObject({
|
||||
totalCount: 1,
|
||||
items: [{ id: "1" }],
|
||||
});
|
||||
const result = await caller.search({ query: "Homarr" });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { parse } from "superjson";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import {
|
||||
apps,
|
||||
assistantConfigurations,
|
||||
assistantMessages,
|
||||
assistantThreads,
|
||||
boards,
|
||||
integrations,
|
||||
integrationUserPermissions,
|
||||
items,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { assistantRouter } from "../assistant";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
vi.mock("@homarr/common/server", () => ({
|
||||
decryptSecret: vi.fn(),
|
||||
encryptSecret: vi.fn(),
|
||||
}));
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({
|
||||
dbEnv: { DRIVER: "better-sqlite3" },
|
||||
}));
|
||||
|
||||
const adminSession = {
|
||||
user: {
|
||||
id: createId(),
|
||||
permissions: ["admin"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createConfiguredAssistantAsync = async () => {
|
||||
const db = createDb();
|
||||
await db.insert(assistantConfigurations).values({
|
||||
id: "default",
|
||||
enabled: true,
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
encryptedApiKey: "encrypted.key",
|
||||
encryptedHeaders: "encrypted.headers",
|
||||
modelId: "example/model",
|
||||
});
|
||||
return db;
|
||||
};
|
||||
|
||||
describe("assistantRouter.getAdminConfiguration", () => {
|
||||
test("defaults an unconfigured Assistant to the Homarr provider", async () => {
|
||||
const caller = assistantRouter.createCaller({ db: createDb(), deviceType: undefined, session: adminSession });
|
||||
|
||||
await expect(caller.getAdminConfiguration()).resolves.toMatchObject({
|
||||
connectionConfigured: false,
|
||||
provider: "homarr",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistantRouter.updateConnection", () => {
|
||||
test("clears the persisted model when the discovery path changes without clearing credentials", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await caller.updateConnection({
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: "/catalog",
|
||||
clearApiKey: false,
|
||||
clearCustomHeaders: false,
|
||||
});
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration).toMatchObject({
|
||||
enabled: false,
|
||||
modelDiscoveryPath: "/catalog",
|
||||
modelId: null,
|
||||
encryptedApiKey: "encrypted.key",
|
||||
encryptedHeaders: "encrypted.headers",
|
||||
});
|
||||
});
|
||||
|
||||
test("disables OpenRouter server tools when the provider destination changes", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({ webSearchEnabled: true })
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await caller.updateConnection({
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
clearApiKey: false,
|
||||
clearCustomHeaders: false,
|
||||
});
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration?.webSearchEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves the persisted model when the saved connection is unchanged", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await caller.updateConnection({
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
clearApiKey: false,
|
||||
clearCustomHeaders: false,
|
||||
});
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration).toMatchObject({
|
||||
enabled: true,
|
||||
modelDiscoveryPath: "/models",
|
||||
modelId: "example/model",
|
||||
});
|
||||
});
|
||||
|
||||
test("never stores administrator credentials for the Homarr provider", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await caller.updateConnection({
|
||||
provider: "homarr",
|
||||
baseUrl: "https://attacker.example/v1",
|
||||
modelDiscoveryPath: "/attacker-models",
|
||||
apiKey: "must-not-be-stored",
|
||||
customHeaders: { "X-Secret": "must-not-be-stored" },
|
||||
clearApiKey: false,
|
||||
clearCustomHeaders: false,
|
||||
});
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration).toMatchObject({
|
||||
provider: "homarr",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
encryptedApiKey: null,
|
||||
encryptedHeaders: null,
|
||||
enabled: false,
|
||||
modelId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistantRouter.updateConfiguration", () => {
|
||||
test("normalizes legacy model labels to the discovered provider ID", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({ encryptedHeaders: null })
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
name: "DeepSeek: DeepSeek V4 Pro",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateConfiguration({
|
||||
enabled: true,
|
||||
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro");
|
||||
});
|
||||
|
||||
test("persists the OpenRouter web-search server tool option", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await caller.updateConfiguration({ enabled: true, modelId: "example/model", webSearchEnabled: true });
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration?.webSearchEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("allows the Homarr OpenRouter proxy to enable web search", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
provider: "homarr",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
encryptedApiKey: null,
|
||||
encryptedHeaders: null,
|
||||
modelId: "homarr/model",
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "homarr/model",
|
||||
name: "Homarr model",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateConfiguration({
|
||||
enabled: true,
|
||||
modelId: "homarr/model",
|
||||
webSearchEnabled: true,
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration?.webSearchEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects OpenRouter server tools for an incompatible provider", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({ provider: "openai", baseUrl: "https://api.openai.com/v1" })
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await expect(
|
||||
caller.updateConfiguration({ enabled: true, modelId: "gpt-5", webSearchEnabled: true }),
|
||||
).rejects.toThrow("Web search requires OpenRouter or an OpenRouter-compatible proxy endpoint.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant conversation features", () => {
|
||||
test("returns safe runtime model options to signed-in users", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
baseUrl: "https://models.example/v1",
|
||||
encryptedHeaders: null,
|
||||
updatedAt: new Date("2026-07-30T14:00:00.000Z"),
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Example Model",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/no-tools",
|
||||
name: "No tools",
|
||||
supported_parameters: [],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/unnamed",
|
||||
name: "",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const options = await caller.getRuntimeOptions();
|
||||
|
||||
expect(options).toEqual({
|
||||
provider: "openrouter",
|
||||
defaultModelId: "example/model",
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: "example/model",
|
||||
name: "Example Model",
|
||||
inputModalities: ["text", "image"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "example/unnamed",
|
||||
name: "example/unnamed",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(options)).not.toContain("encrypted");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns permission-checked entities for composer mentions", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db.insert(users).values({ id: adminSession.user.id });
|
||||
await db.insert(apps).values({ id: "app-1", name: "Plex", iconUrl: "https://example.com/plex.svg" });
|
||||
await db.insert(boards).values({ id: "board-1", name: "Home", isPublic: true });
|
||||
await db.insert(items).values({ id: "widget-1", boardId: "board-1", kind: "clock" });
|
||||
await db.insert(integrations).values({
|
||||
id: "integration-1",
|
||||
name: "Media server",
|
||||
kind: "plex",
|
||||
url: "https://example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "integration-1",
|
||||
userId: adminSession.user.id,
|
||||
permission: "use",
|
||||
});
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const entities = await caller.getContextEntities();
|
||||
|
||||
expect(entities).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "app-1",
|
||||
type: "app",
|
||||
label: "Plex",
|
||||
iconUrl: "https://example.com/plex.svg",
|
||||
}),
|
||||
expect.objectContaining({ id: "board-1", type: "board", label: "Home" }),
|
||||
expect.objectContaining({ id: "widget-1", type: "widget", boardId: "board-1" }),
|
||||
expect.objectContaining({
|
||||
id: "integration-1",
|
||||
type: "integration",
|
||||
label: "Media server",
|
||||
iconUrl: expect.stringContaining("plex"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("persists a discovered model choice on the owned conversation", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db.insert(users).values({ id: adminSession.user.id });
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
baseUrl: "https://thread-models.example/v1",
|
||||
encryptedHeaders: null,
|
||||
updatedAt: new Date("2026-07-30T15:00:00.000Z"),
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
const thread = await caller.createThread();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "example/model",
|
||||
name: "Default",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "example/reasoning",
|
||||
name: "Reasoning",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
const storedThread = await db.query.assistantThreads.findFirst({
|
||||
where: eq(assistantThreads.id, thread.id),
|
||||
});
|
||||
expect(storedThread?.modelId).toBe("example/reasoning");
|
||||
});
|
||||
|
||||
test("persists feedback on an owned assistant message", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db.insert(users).values({ id: adminSession.user.id });
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
const thread = await caller.createThread();
|
||||
await caller.appendMessage({
|
||||
threadId: thread.id,
|
||||
id: "message-1",
|
||||
parentId: null,
|
||||
format: "ai-sdk/v6",
|
||||
content: { role: "assistant", parts: [{ type: "text", text: "Hello" }], metadata: { custom: {} } },
|
||||
});
|
||||
|
||||
await caller.submitFeedback({ threadId: thread.id, messageId: "message-1", type: "positive" });
|
||||
|
||||
const [message] = await db.select().from(assistantMessages);
|
||||
expect(message).toBeDefined();
|
||||
expect(parse(message?.content ?? "")).toMatchObject({
|
||||
metadata: { submittedFeedback: { type: "positive" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns complete stored tool messages for conversation debugging exports", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db.insert(users).values({ id: adminSession.user.id });
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
const thread = await caller.createThread();
|
||||
const content = {
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{
|
||||
type: "tool-icon_findIcons",
|
||||
toolCallId: "call-icons",
|
||||
state: "output-available",
|
||||
input: { searchText: "homarr" },
|
||||
output: { icons: [{ name: "homarr.svg" }] },
|
||||
},
|
||||
],
|
||||
metadata: { custom: { telemetry: { requestId: "request-1" } } },
|
||||
};
|
||||
await caller.appendMessage({
|
||||
threadId: thread.id,
|
||||
id: "message-with-tool",
|
||||
parentId: null,
|
||||
format: "ai-sdk/v6",
|
||||
content,
|
||||
});
|
||||
|
||||
const result = await caller.getThread({ threadId: thread.id });
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
id: "message-with-tool",
|
||||
parentId: null,
|
||||
format: "ai-sdk/v6",
|
||||
content,
|
||||
});
|
||||
expect(result.messages[0]?.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { parse } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq as dbEq } from "@homarr/db";
|
||||
import {
|
||||
boards,
|
||||
integrations,
|
||||
integrationUserPermissions,
|
||||
items,
|
||||
layouts,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { boardRouter } from "../board";
|
||||
|
||||
const createBoardCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId });
|
||||
const layoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
});
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
|
||||
const session = {
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
return {
|
||||
db,
|
||||
userId,
|
||||
boardId,
|
||||
layoutId,
|
||||
sectionId,
|
||||
caller: boardRouter.createCaller({ db, deviceType: undefined, session }),
|
||||
};
|
||||
};
|
||||
|
||||
describe("board.addItem", () => {
|
||||
test("preserves configured notebook content when placing the widget", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
const content = "<h2>Self-host Plex</h2><p>Install Docker and configure your media volumes.</p>";
|
||||
|
||||
const result = await caller.addItem({
|
||||
boardId,
|
||||
kind: "notebook",
|
||||
options: { content, showToolbar: true, allowReadOnlyCheck: true },
|
||||
integrationIds: [],
|
||||
});
|
||||
|
||||
const item = await db.query.items.findFirst({ where: (table, { eq }) => eq(table.id, result.itemId) });
|
||||
expect(item?.kind).toBe("notebook");
|
||||
expect(parse(item?.options ?? "{}")).toEqual({
|
||||
content,
|
||||
showToolbar: true,
|
||||
allowReadOnlyCheck: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("places a sized notebook on each layout without exceeding its columns", async () => {
|
||||
const { db, boardId, caller, layoutId } = await createBoardCallerAsync();
|
||||
const mobileLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
boardId,
|
||||
columnCount: 4,
|
||||
breakpoint: 640,
|
||||
});
|
||||
|
||||
const result = await caller.addItem({
|
||||
boardId,
|
||||
kind: "notebook",
|
||||
options: { content: "<p>Stack summary</p>" },
|
||||
size: { width: 8, height: 4 },
|
||||
integrationIds: [],
|
||||
});
|
||||
const placements = await db.query.itemLayouts.findMany({
|
||||
where: (table, { eq }) => eq(table.itemId, result.itemId),
|
||||
});
|
||||
|
||||
expect(placements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ layoutId, width: 8, height: 4, xOffset: 0, yOffset: 0 }),
|
||||
expect.objectContaining({ layoutId: mobileLayoutId, width: 4, height: 4, xOffset: 0, yOffset: 0 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an oversized item before creating it", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
await expect(
|
||||
caller.addItem({
|
||||
boardId,
|
||||
kind: "notebook",
|
||||
options: {},
|
||||
size: { width: 25, height: 4 },
|
||||
integrationIds: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("places a new item after the complete footprint of an existing container", async () => {
|
||||
const { db, boardId, caller, layoutId, sectionId } = await createBoardCallerAsync();
|
||||
const mobileLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
boardId,
|
||||
columnCount: 4,
|
||||
breakpoint: 640,
|
||||
});
|
||||
const containerId = createId();
|
||||
await db.insert(sections).values({ id: containerId, boardId, kind: "container" });
|
||||
await db.insert(sectionLayouts).values([
|
||||
{
|
||||
sectionId: containerId,
|
||||
parentSectionId: sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
sectionId: containerId,
|
||||
parentSectionId: sectionId,
|
||||
layoutId: mobileLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] });
|
||||
const placements = await db.query.itemLayouts.findMany({
|
||||
where: (table, { eq }) => eq(table.itemId, result.itemId),
|
||||
});
|
||||
|
||||
expect(placements).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ layoutId, xOffset: 4, yOffset: 0, width: 1, height: 1 }),
|
||||
expect.objectContaining({ layoutId: mobileLayoutId, xOffset: 2, yOffset: 0, width: 1, height: 1 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the main section width instead of placing into gutter columns", async () => {
|
||||
const { db, boardId, caller, layoutId, sectionId } = await createBoardCallerAsync();
|
||||
await db
|
||||
.update(layouts)
|
||||
.set({ leftGutterColumnCount: 2, rightGutterColumnCount: 2 })
|
||||
.where(dbEq(layouts.id, layoutId));
|
||||
const containerId = createId();
|
||||
await db.insert(sections).values({ id: containerId, boardId, kind: "container" });
|
||||
await db.insert(sectionLayouts).values({
|
||||
sectionId: containerId,
|
||||
parentSectionId: sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 8,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
const result = await caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] });
|
||||
const placement = await db.query.itemLayouts.findFirst({
|
||||
where: (table, { eq }) => eq(table.itemId, result.itemId),
|
||||
});
|
||||
|
||||
expect(placement).toMatchObject({ xOffset: 0, yOffset: 1 });
|
||||
});
|
||||
|
||||
test("serializes concurrent automatic placements on the same board", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
const results = await Promise.all([
|
||||
caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] }),
|
||||
caller.addItem({ boardId, kind: "app", options: {}, integrationIds: [] }),
|
||||
]);
|
||||
const placements = await db.query.itemLayouts.findMany({
|
||||
where: (table, { inArray }) =>
|
||||
inArray(
|
||||
table.itemId,
|
||||
results.map((result) => result.itemId),
|
||||
),
|
||||
});
|
||||
|
||||
expect(placements).toHaveLength(2);
|
||||
expect(new Set(placements.map((placement) => `${placement.xOffset}:${placement.yOffset}`)).size).toBe(2);
|
||||
});
|
||||
|
||||
test("accepts an integration the current user can use", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Plex",
|
||||
kind: "plex",
|
||||
url: "https://plex.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).resolves.toMatchObject({ itemId: expect.any(String) });
|
||||
});
|
||||
|
||||
test("rejects an integration the current user cannot use", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Private Plex",
|
||||
kind: "plex",
|
||||
url: "https://private.example.com",
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).rejects.toThrow("Integration not found");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("rejects a missing required integration", async () => {
|
||||
const { db, boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
await expect(caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [] })).rejects.toThrow(
|
||||
"mediaServer requires an integration",
|
||||
);
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("allows an explicitly optional integration to be omitted", async () => {
|
||||
const { boardId, caller } = await createBoardCallerAsync();
|
||||
|
||||
await expect(caller.addItem({ boardId, kind: "calendar", options: {}, integrationIds: [] })).resolves.toMatchObject(
|
||||
{ itemId: expect.any(String) },
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an incompatible integration kind", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const integrationId = createId();
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "https://sonarr.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
await expect(
|
||||
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
|
||||
).rejects.toThrow("mediaServer does not support integration kind: sonarr");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
|
||||
test("rejects more integrations than the widget supports", async () => {
|
||||
const { db, userId, boardId, caller } = await createBoardCallerAsync();
|
||||
const navidromeId = createId();
|
||||
const audiobookshelfId = createId();
|
||||
await db.insert(integrations).values([
|
||||
{ id: navidromeId, name: "Navidrome", kind: "navidrome", url: "https://music.example.com" },
|
||||
{
|
||||
id: audiobookshelfId,
|
||||
name: "Audiobookshelf",
|
||||
kind: "audiobookshelf",
|
||||
url: "https://books.example.com",
|
||||
},
|
||||
]);
|
||||
await db.insert(integrationUserPermissions).values([
|
||||
{ integrationId: navidromeId, userId, permission: "use" },
|
||||
{ integrationId: audiobookshelfId, userId, permission: "use" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
caller.addItem({
|
||||
boardId,
|
||||
kind: "audioStats",
|
||||
options: {},
|
||||
integrationIds: [navidromeId, audiobookshelfId],
|
||||
}),
|
||||
).rejects.toThrow("audioStats supports at most 1 integration");
|
||||
|
||||
await expect(db.$count(items)).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { throwIfDockerOperationsFailed } from "../../docker/docker-operation-results";
|
||||
|
||||
describe("Docker operation results", () => {
|
||||
test("accepts an all-success result", async () => {
|
||||
const results = await Promise.allSettled([Promise.resolve(), Promise.resolve()]);
|
||||
|
||||
expect(() => throwIfDockerOperationsFailed(results)).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws a sanitized error after a partial failure", async () => {
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve(),
|
||||
Promise.reject(new Error("sensitive Docker daemon response")),
|
||||
]);
|
||||
|
||||
expect(() => throwIfDockerOperationsFailed(results)).toThrow(
|
||||
new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "One or more Docker container operations failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,70 @@ import { objectKeys } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
||||
import type { DockerEndpointStatus } from "@homarr/docker";
|
||||
|
||||
import type { RouterInputs } from "../../..";
|
||||
import type { RouterInputs, RouterOutputs } from "../../..";
|
||||
import { dockerRouter } from "../../docker/docker-router";
|
||||
|
||||
const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn());
|
||||
const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const invalidateDockerCacheMock = vi.hoisted(() => vi.fn());
|
||||
const resetDockerSingletonMock = vi.hoisted(() => vi.fn());
|
||||
const getDockerDataAsyncMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => Promise<{
|
||||
data: {
|
||||
containers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
endpoints: DockerEndpointStatus[];
|
||||
};
|
||||
timestamp: Date;
|
||||
}>
|
||||
>(async () => ({
|
||||
data: { containers: [], endpoints: [] },
|
||||
timestamp: new Date(),
|
||||
})),
|
||||
);
|
||||
const findIntegrationsAsyncMock = vi.fn(
|
||||
async (): Promise<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "sonarr";
|
||||
url: string;
|
||||
appId: string | null;
|
||||
items?: { itemId: string }[];
|
||||
secrets?: { kind: "apiKey" }[];
|
||||
}[]
|
||||
> => [],
|
||||
);
|
||||
const findAppsAsyncMock = vi.fn(
|
||||
async (): Promise<{ id: string; name: string; href: string | null; iconUrl: string }[]> => [],
|
||||
);
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
vi.mock("@homarr/docker", () => ({
|
||||
DockerSingleton: {
|
||||
getInstances: () => [
|
||||
{
|
||||
instance: {
|
||||
getContainer: () => ({
|
||||
inspect: (callback: (error: null, data: object) => void) => callback(null, {}),
|
||||
start: () => Promise.resolve(),
|
||||
stop: () => Promise.resolve(),
|
||||
restart: () => Promise.resolve(),
|
||||
remove: () => Promise.resolve(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
reset: resetDockerSingletonMock,
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/request-handler/docker", () => ({
|
||||
findDockerContainerAsync: findDockerContainerAsyncMock,
|
||||
hasDockerEndpointCapability: hasDockerEndpointCapabilityMock,
|
||||
getDockerEndpointsAsync: () => [],
|
||||
getContainerLogsAsync: async () => {
|
||||
await Promise.resolve();
|
||||
return "logs";
|
||||
@@ -22,10 +79,9 @@ vi.mock("@homarr/request-handler/docker", () => ({
|
||||
return () => undefined;
|
||||
},
|
||||
dockerContainersRequestHandler: {
|
||||
invalidateCache: invalidateDockerCacheMock,
|
||||
handler: () => ({
|
||||
getDataAsync: async () => {
|
||||
return await Promise.resolve({ data: [], timestamp: new Date() });
|
||||
},
|
||||
getDataAsync: getDockerDataAsyncMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -50,20 +106,45 @@ const procedureKeys = objectKeys(dockerRouter._def.procedures);
|
||||
const validInputs: {
|
||||
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
|
||||
} = {
|
||||
reconcileServices: undefined,
|
||||
getServiceHealth: undefined,
|
||||
refreshInventory: undefined,
|
||||
getEndpoints: undefined,
|
||||
getContainers: undefined,
|
||||
startAll: { ids: ["1"] },
|
||||
stopAll: { ids: ["1"] },
|
||||
restartAll: { ids: ["1"] },
|
||||
removeAll: { ids: ["1"] },
|
||||
logs: { id: "1", tail: 200 },
|
||||
subscribeLogs: { id: "1", tail: 200 },
|
||||
startAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
stopAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
restartAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
removeAll: { targets: [{ endpointId: "one", id: "1" }] },
|
||||
logs: { endpointId: "one", id: "1", tail: 200 },
|
||||
subscribeLogs: { endpointId: "one", id: "1", tail: 200 },
|
||||
};
|
||||
|
||||
const database = {
|
||||
query: {
|
||||
integrations: { findMany: findIntegrationsAsyncMock },
|
||||
apps: { findMany: findAppsAsyncMock },
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
const createDockerContainer = () => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const createAdminCaller = () =>
|
||||
dockerRouter.createCaller({
|
||||
db: database,
|
||||
deviceType: undefined,
|
||||
session: createSessionWithPermissions("admin"),
|
||||
});
|
||||
|
||||
describe("All procedures should only be accessible for users with admin permission", () => {
|
||||
test.each(procedureKeys)("Procedure %s should be accessible for users with admin permission", async (procedure) => {
|
||||
// Arrange
|
||||
const caller = dockerRouter.createCaller({
|
||||
db: null as unknown as Database,
|
||||
db: database,
|
||||
deviceType: undefined,
|
||||
session: createSessionWithPermissions("admin"),
|
||||
});
|
||||
@@ -105,3 +186,384 @@ describe("All procedures should only be accessible for users with admin permissi
|
||||
await expect(act()).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Docker action targeting", () => {
|
||||
test.each(["startAll", "stopAll", "restartAll", "removeAll"] as const)(
|
||||
"%s requires a bounded non-empty target list",
|
||||
async (procedure) => {
|
||||
const caller = createAdminCaller();
|
||||
|
||||
await expect(caller[procedure]({ targets: [] })).rejects.toThrow();
|
||||
await expect(
|
||||
caller[procedure]({
|
||||
targets: Array.from({ length: 101 }, (_, index) => ({ endpointId: "endpoint", id: String(index) })),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
test("enforces endpoint capabilities before running a lifecycle action", async () => {
|
||||
hasDockerEndpointCapabilityMock.mockReturnValueOnce(false);
|
||||
|
||||
const result = await createAdminCaller().stopAll({ targets: [{ endpointId: "readonly", id: "one" }] });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
target: { endpointId: "readonly", id: "one" },
|
||||
success: false,
|
||||
error: "Endpoint does not permit lifecycle actions",
|
||||
},
|
||||
]);
|
||||
expect(findDockerContainerAsyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("targets duplicate container IDs on their explicit endpoints", async () => {
|
||||
const first = createDockerContainer();
|
||||
const second = createDockerContainer();
|
||||
findDockerContainerAsyncMock.mockImplementation(async ({ endpointId }) =>
|
||||
endpointId === "first" ? first : second,
|
||||
);
|
||||
|
||||
const result = await createAdminCaller().startAll({
|
||||
targets: [
|
||||
{ endpointId: "first", id: "duplicate" },
|
||||
{ endpointId: "second", id: "duplicate" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(first.start).toHaveBeenCalledOnce();
|
||||
expect(second.start).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual([
|
||||
{ target: { endpointId: "first", id: "duplicate" }, success: true },
|
||||
{ target: { endpointId: "second", id: "duplicate" }, success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports partial failures per target", async () => {
|
||||
const healthy = createDockerContainer();
|
||||
const failing = createDockerContainer();
|
||||
failing.restart.mockRejectedValue(new Error("proxy denied restart"));
|
||||
findDockerContainerAsyncMock.mockImplementation(async ({ endpointId }) =>
|
||||
endpointId === "healthy" ? healthy : failing,
|
||||
);
|
||||
|
||||
const result = await createAdminCaller().restartAll({
|
||||
targets: [
|
||||
{ endpointId: "healthy", id: "one" },
|
||||
{ endpointId: "failing", id: "two" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ target: { endpointId: "healthy", id: "one" }, success: true },
|
||||
{
|
||||
target: { endpointId: "failing", id: "two" },
|
||||
success: false,
|
||||
error: "proxy denied restart",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("distinguishes unavailable endpoints from an empty inventory", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [],
|
||||
endpoints: [{ id: "remote", name: "Remote Docker", status: "unavailable" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const result = await createAdminCaller().getContainers();
|
||||
|
||||
expect(result.containers).toEqual([]);
|
||||
expect(result.endpoints).toEqual([{ id: "remote", name: "Remote Docker", status: "unavailable" }]);
|
||||
});
|
||||
|
||||
test("keeps multiple recognized services of the same integration kind", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [
|
||||
createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr-home", publicPort: 8989 }),
|
||||
createInventoryContainer({ endpointId: "remote", id: "two", name: "sonarr-remote", publicPort: 18989 }),
|
||||
],
|
||||
endpoints: [
|
||||
{ id: "home", name: "Home", status: "available" },
|
||||
{ id: "remote", name: "Remote", status: "available" },
|
||||
] satisfies DockerEndpointStatus[],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
expect(result.candidates.map(({ match }) => match?.kind)).toEqual(["sonarr", "sonarr"]);
|
||||
expect(result.candidates.map(({ candidateKey }) => candidateKey)).toEqual(["home:one", "remote:two"]);
|
||||
});
|
||||
|
||||
test("preserves unavailable endpoints beside available service candidates", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [
|
||||
{ id: "home", name: "Home", status: "available" },
|
||||
{ id: "remote", name: "Remote", status: "unavailable" },
|
||||
] satisfies DockerEndpointStatus[],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
expect(result.endpoints).toContainEqual({ id: "remote", name: "Remote", status: "unavailable" });
|
||||
});
|
||||
|
||||
test("does not adopt an ambiguous existing integration", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "first", name: "Sonarr one", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
{ id: "second", name: "Sonarr two", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.integration).toBeNull();
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
|
||||
expect(result.candidates[0]?.state).toBe("newRecognized");
|
||||
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
|
||||
});
|
||||
|
||||
test("prefers a unique exact integration match over duplicate name matches", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "exact",
|
||||
name: "Primary",
|
||||
kind: "sonarr",
|
||||
url: "http://home.example:8989",
|
||||
appId: null,
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
{
|
||||
id: "name-one",
|
||||
name: "sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://other.example:8989",
|
||||
appId: null,
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
{
|
||||
id: "name-two",
|
||||
name: "sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://third.example:8989",
|
||||
appId: null,
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.integration?.id).toBe("exact");
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false);
|
||||
});
|
||||
|
||||
test("does not fall back to a unique name when exact integration matches are ambiguous", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "named", name: "sonarr", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
{ id: "other", name: "Other", kind: "sonarr", url: "http://home.example:8989", appId: null },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.integration).toBeNull();
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(true);
|
||||
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
|
||||
});
|
||||
|
||||
test("prefers an explicitly linked app over duplicate app matches", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "integration",
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://home.example:8989",
|
||||
appId: "linked",
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
]);
|
||||
findAppsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "linked", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
|
||||
{ id: "duplicate", name: "Sonarr duplicate", href: "http://home.example:8989", iconUrl: "" },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.representation.app?.id).toBe("linked");
|
||||
expect(result.candidates[0]?.representation.signals.ambiguous).toBe(false);
|
||||
expect(result.candidates[0]?.state).toBe("linked");
|
||||
});
|
||||
|
||||
test("marks a linked integration as moved when only its app URL still matches", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "integration",
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://old.example:8989",
|
||||
appId: "linked",
|
||||
items: [],
|
||||
secrets: [],
|
||||
},
|
||||
]);
|
||||
findAppsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "linked", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().reconcileServices();
|
||||
|
||||
expect(result.candidates[0]?.state).toBe("moved");
|
||||
expect(result.candidates[0]?.nextAction).toBe("reviewIntegration");
|
||||
expect(result.candidates[0]?.representation.signals).toMatchObject({
|
||||
exactIntegrationUrl: false,
|
||||
exactAppUrl: true,
|
||||
linked: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not offer to create an app when existing app matches are ambiguous", async () => {
|
||||
const dockerData = {
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
} satisfies Awaited<ReturnType<typeof getDockerDataAsyncMock>>;
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce(dockerData).mockResolvedValueOnce(dockerData);
|
||||
const apps = [
|
||||
{ id: "first", name: "Sonarr one", href: "http://home.example:8989", iconUrl: "" },
|
||||
{ id: "second", name: "Sonarr two", href: "http://home.example:8989", iconUrl: "" },
|
||||
] satisfies Awaited<ReturnType<typeof findAppsAsyncMock>>;
|
||||
findAppsAsyncMock.mockResolvedValueOnce(apps).mockResolvedValueOnce(apps);
|
||||
|
||||
const reconciliation = await createAdminCaller().reconcileServices();
|
||||
const health = await createAdminCaller().getServiceHealth();
|
||||
|
||||
expect(reconciliation.candidates[0]?.representation.app).toBeNull();
|
||||
expect(reconciliation.candidates[0]?.representation.signals.ambiguous).toBe(true);
|
||||
expect(reconciliation.candidates[0]?.nextAction).toBe("viewRepresentation");
|
||||
expect(health.services[0]?.layers).toContainEqual({
|
||||
layer: "appRepresentation",
|
||||
status: "missing",
|
||||
nextAction: "viewRepresentation",
|
||||
});
|
||||
});
|
||||
|
||||
test("invalidates the shared Docker inventory on explicit refresh", async () => {
|
||||
await createAdminCaller().refreshInventory();
|
||||
|
||||
expect(invalidateDockerCacheMock).toHaveBeenCalledOnce();
|
||||
expect(resetDockerSingletonMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("projects persisted service layers without inventing runtime health", async () => {
|
||||
getDockerDataAsyncMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
containers: [createInventoryContainer({ endpointId: "home", id: "one", name: "sonarr", publicPort: 8989 })],
|
||||
endpoints: [{ id: "home", name: "Home", status: "available" }],
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
findIntegrationsAsyncMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: "integration",
|
||||
name: "Sonarr",
|
||||
kind: "sonarr",
|
||||
url: "http://home.example:8989",
|
||||
appId: "app",
|
||||
items: [{ itemId: "widget" }],
|
||||
secrets: [{ kind: "apiKey" }],
|
||||
},
|
||||
]);
|
||||
findAppsAsyncMock.mockResolvedValueOnce([
|
||||
{ id: "app", name: "Sonarr", href: "http://home.example:8989", iconUrl: "" },
|
||||
]);
|
||||
|
||||
const result = await createAdminCaller().getServiceHealth();
|
||||
|
||||
expect(result.services[0]?.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ layer: "docker", status: "available", nextAction: "none" },
|
||||
{ layer: "integrationConfiguration", status: "configured", nextAction: "none" },
|
||||
{ layer: "authentication", status: "notObserved", nextAction: "testConnection" },
|
||||
{ layer: "apiRequest", status: "notObserved", nextAction: "openIntegrationDiagnostics" },
|
||||
{ layer: "appRepresentation", status: "linked", nextAction: "none" },
|
||||
{ layer: "widgetConfiguration", status: "linked", nextAction: "none" },
|
||||
{ layer: "widgetQuery", status: "notObserved", nextAction: "openBoard" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const createInventoryContainer = ({
|
||||
endpointId,
|
||||
id,
|
||||
name,
|
||||
publicPort,
|
||||
}: {
|
||||
endpointId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
publicPort: number;
|
||||
}) => ({
|
||||
id,
|
||||
endpointId,
|
||||
endpointName: endpointId,
|
||||
resourceId: `${endpointId}:${id}`,
|
||||
name,
|
||||
host: `${endpointId}.example:2375`,
|
||||
state: "running" as const,
|
||||
image: "lscr.io/linuxserver/sonarr:latest",
|
||||
iconUrl: null,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
ports: [{ IP: "0.0.0.0", PrivatePort: 8989, PublicPort: publicPort, Type: "tcp" }],
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { groups, groupPermissions, onboarding } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { groupRouter } from "../group";
|
||||
|
||||
const adminSession = {
|
||||
user: { id: "admin", permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
describe("group.createInitialExternalGroup", () => {
|
||||
it("commits exactly one concurrent group-to-setup transition", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "group", previousStep: "start" });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
caller.createInitialExternalGroup({ name: "LDAP admins" }),
|
||||
caller.createInitialExternalGroup({ name: "OIDC admins" }),
|
||||
]);
|
||||
|
||||
expect(results.map((result) => result.status).toSorted()).toEqual(["fulfilled", "rejected"]);
|
||||
expect(results.find((result) => result.status === "rejected")).toMatchObject({
|
||||
reason: expect.objectContaining({ message: "The initial external group was already created." }),
|
||||
});
|
||||
const createdGroups = await db.select().from(groups);
|
||||
expect(createdGroups).toHaveLength(1);
|
||||
expect(["LDAP admins", "OIDC admins"]).toContain(createdGroups[0]?.name);
|
||||
expect(await db.select().from(groupPermissions)).toEqual([
|
||||
expect.objectContaining({ groupId: createdGroups[0]?.id, permission: "admin" }),
|
||||
]);
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
});
|
||||
});
|
||||
@@ -449,6 +449,165 @@ describe("savePermissions should save permissions for group", () => {
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["integration-use-all", "board-full-all"]);
|
||||
});
|
||||
|
||||
test("with a standalone create permission it should not escalate to its parent permission", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-create"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission)).toEqual(["board-create"]);
|
||||
});
|
||||
|
||||
test.each([["app-create"], ["integration-create"], ["search-engine-create"], ["media-upload"]] as const)(
|
||||
"with %s alone it should persist exactly that permission",
|
||||
async (permission) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: [permission] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission: key }) => key)).toEqual([permission]);
|
||||
},
|
||||
);
|
||||
|
||||
test("with a create permission and its parent it should persist exactly two rows", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["app-create", "app-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(2);
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["app-create", "app-modify-all"]);
|
||||
});
|
||||
|
||||
test("with duplicate permissions it should persist each permission only once", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: ["board-view-all", "board-view-all", "board-modify-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-modify-all", "board-view-all"]);
|
||||
});
|
||||
|
||||
test("saving the same permissions twice should be idempotent", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
await caller.savePermissions({ groupId, permissions: ["board-create", "media-upload"] });
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.map(({ permission }) => permission).toSorted()).toEqual(["board-create", "media-upload"]);
|
||||
});
|
||||
|
||||
test("with an empty permission list it should clear all existing permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
position: 1,
|
||||
});
|
||||
await db.insert(groupPermissions).values([
|
||||
{ groupId, permission: "admin" },
|
||||
{ groupId, permission: "board-full-all" },
|
||||
]);
|
||||
|
||||
// Act
|
||||
await caller.savePermissions({
|
||||
groupId,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const permissions = await db.query.groupPermissions.findMany({
|
||||
where: eq(groupPermissions.groupId, groupId),
|
||||
});
|
||||
|
||||
expect(permissions.length).toBe(0);
|
||||
});
|
||||
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { iconRepositories, icons } from "@homarr/db/schema";
|
||||
@@ -6,6 +8,73 @@ import { createDb } from "@homarr/db/test";
|
||||
import { iconsRouter } from "../icons";
|
||||
|
||||
describe("findIcons", () => {
|
||||
test("returns only repositories containing matches for a search", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(iconRepositories).values([
|
||||
{ id: "dashboard-icons", slug: "homarr-labs/dashboard-icons" },
|
||||
{ id: "other-icons", slug: "example/other-icons" },
|
||||
]);
|
||||
await db.insert(icons).values([
|
||||
{
|
||||
id: "discord-svg",
|
||||
name: "discord.svg",
|
||||
url: "https://cdn.example.com/discord.svg",
|
||||
checksum: "discord",
|
||||
iconRepositoryId: "dashboard-icons",
|
||||
},
|
||||
{
|
||||
id: "homarr-svg",
|
||||
name: "homarr.svg",
|
||||
url: "https://cdn.example.com/homarr.svg",
|
||||
checksum: "homarr",
|
||||
iconRepositoryId: "other-icons",
|
||||
},
|
||||
]);
|
||||
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
const result = await caller.findIcons({ searchText: "discord", limitPerGroup: 6 });
|
||||
|
||||
expect(result.countIcons).toBe(2);
|
||||
expect(result.icons).toHaveLength(1);
|
||||
expect(result.icons[0]).toMatchObject({
|
||||
slug: "homarr-labs/dashboard-icons",
|
||||
icons: [{ name: "discord.svg", url: "https://cdn.example.com/discord.svg" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps repositories when browsing without a search", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(iconRepositories).values({ id: "empty", slug: "example/empty" });
|
||||
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
const result = await caller.findIcons({ limitPerGroup: 6 });
|
||||
|
||||
expect(result.icons).toMatchObject([{ slug: "example/empty", icons: [] }]);
|
||||
});
|
||||
|
||||
test("finds the Homarr icon and returns its exact local repository URL", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(iconRepositories).values({ id: "dashboard-icons", slug: "homarr-labs/dashboard-icons" });
|
||||
await db.insert(icons).values({
|
||||
id: "homarr-svg",
|
||||
name: "homarr.svg",
|
||||
url: "https://cdn.example.com/homarr.svg",
|
||||
checksum: "homarr",
|
||||
iconRepositoryId: "dashboard-icons",
|
||||
});
|
||||
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.findIcons({ searchText: "homarr" })).resolves.toMatchObject({
|
||||
countIcons: 1,
|
||||
icons: [
|
||||
{
|
||||
slug: "homarr-labs/dashboard-icons",
|
||||
icons: [{ name: "homarr.svg", url: "https://cdn.example.com/homarr.svg" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
|
||||
"matches normalized search %s before applying the group limit",
|
||||
async (searchText) => {
|
||||
|
||||
@@ -29,12 +29,12 @@ vi.mock("../../integration/integration-test-connection", () => ({
|
||||
}));
|
||||
|
||||
describe("all should return all integrations", () => {
|
||||
test("with any session should return all integrations", async () => {
|
||||
test("with integration full access should return all integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
@@ -56,6 +56,172 @@ describe("all should return all integrations", () => {
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]!.kind).toBe("plex");
|
||||
expect(result[1]!.kind).toBe("homeAssistant");
|
||||
expect(result.every(({ permissions }) => permissions.hasFullAccess && permissions.hasUseAccess)).toBe(true);
|
||||
});
|
||||
|
||||
test("with integration full access and no per-integration rows should return all integrations with full access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-full-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(2);
|
||||
for (const integration of result) {
|
||||
expect(integration.permissions.hasFullAccess).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("with integration use access should return all integrations", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-use-all"]),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
test("without global integration access should only return integrations with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
|
||||
test("with a per-integration interact grant should return the integration with use access", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "interact",
|
||||
});
|
||||
|
||||
const result = await caller.all();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]!.permissions.hasUseAccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search should return accessible integrations", () => {
|
||||
test("without global integration access should only return integrations with use access as plain rows", async () => {
|
||||
const db = createDb();
|
||||
const caller = integrationRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(),
|
||||
});
|
||||
|
||||
await db.insert(integrations).values([
|
||||
{
|
||||
id: "1",
|
||||
name: "Home assistant",
|
||||
kind: "homeAssistant",
|
||||
url: "http://homeassist.local",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Home plex server",
|
||||
kind: "plex",
|
||||
url: "http://plex.local",
|
||||
},
|
||||
]);
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId: "1",
|
||||
userId: defaultUserId,
|
||||
permission: "use",
|
||||
});
|
||||
|
||||
const result = await caller.search({ query: "Home" });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe("1");
|
||||
expect(result[0]).not.toHaveProperty("userPermissions");
|
||||
expect(result[0]).not.toHaveProperty("groupPermissions");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,7 +478,7 @@ describe("create should create a new integration", () => {
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
await caller.create(input);
|
||||
const result = await caller.create(input);
|
||||
vi.useRealTimers();
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
@@ -327,6 +493,15 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
expect(result).toEqual({
|
||||
integration: {
|
||||
id: dbIntegration!.id,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
},
|
||||
appId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("with create integration access should not create a search engine for media request search integrations", async () => {
|
||||
@@ -347,7 +522,7 @@ describe("create should create a new integration", () => {
|
||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(fakeNow);
|
||||
await caller.create(input);
|
||||
const result = await caller.create(input);
|
||||
vi.useRealTimers();
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
@@ -363,6 +538,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||
expect(result).toMatchObject({ appId: dbIntegration!.appId, integration: { id: dbIntegration!.id } });
|
||||
|
||||
expect(dbSearchEngine).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as homarrDefinitions from "@homarr/definitions";
|
||||
import * as homarrIntegrations from "@homarr/integrations";
|
||||
import type * as homarrIntegrations from "@homarr/integrations";
|
||||
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
|
||||
|
||||
import { testConnectionAsync } from "../../integration/integration-test-connection";
|
||||
|
||||
@@ -18,7 +19,7 @@ vi.mock("@homarr/common/server", async (importActual) => {
|
||||
describe("testConnectionAsync should run test connection of integration", () => {
|
||||
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -61,7 +62,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -111,7 +112,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -161,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -215,7 +216,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("create should create a new invite expiring on the specified date with
|
||||
|
||||
// Act
|
||||
const result = await caller.createInvite({
|
||||
expirationDate,
|
||||
expirationDate: expirationDate.toISOString(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { locationRouter } from "../location";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: mocks.fetch,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
|
||||
withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise<Response>) =>
|
||||
await callback(new AbortController().signal),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] })));
|
||||
});
|
||||
|
||||
describe("locationRouter", () => {
|
||||
test("encodes the complete search text as one name parameter", async () => {
|
||||
const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
|
||||
|
||||
await caller.searchCity({ query: "Paris&language=xx&token=secret" });
|
||||
|
||||
const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string);
|
||||
expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret");
|
||||
expect(requestedUrl.searchParams.has("language")).toBe(false);
|
||||
expect(requestedUrl.searchParams.has("token")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { LoggerMessage } from "@homarr/redis";
|
||||
|
||||
import { logRouter } from "../log";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getRecentAsync: vi.fn(),
|
||||
subscribeAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
loggingChannel: {
|
||||
getRecentAsync: mocks.getRecentAsync,
|
||||
subscribeAsync: mocks.subscribeAsync,
|
||||
},
|
||||
}));
|
||||
|
||||
const session = {
|
||||
user: {
|
||||
id: "log-viewer",
|
||||
permissions: ["other-view-logs"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date(0).toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createMessage = (id: string, timestamp: number): LoggerMessage => ({
|
||||
id,
|
||||
timestamp: new Date(timestamp),
|
||||
level: "info",
|
||||
message: id,
|
||||
});
|
||||
|
||||
describe("log.subscribe", () => {
|
||||
test("emits history before buffered live messages without duplicates", async () => {
|
||||
const historyDeferred = Promise.withResolvers<LoggerMessage[]>();
|
||||
let onMessage: ((message: LoggerMessage) => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
|
||||
mocks.subscribeAsync.mockImplementation(async (callback: (message: LoggerMessage) => void) => {
|
||||
onMessage = callback;
|
||||
return unsubscribe;
|
||||
});
|
||||
mocks.getRecentAsync.mockReturnValue(historyDeferred.promise);
|
||||
|
||||
const caller = logRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
const subscription = await caller.subscribe({ levels: ["info"] });
|
||||
const events: object[] = [];
|
||||
const observer = subscription.subscribe({
|
||||
next(event) {
|
||||
events.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mocks.getRecentAsync).toHaveBeenCalledOnce());
|
||||
|
||||
const retainedMessage = createMessage("retained", 1);
|
||||
const liveMessage = createMessage("live", 2);
|
||||
onMessage?.(retainedMessage);
|
||||
onMessage?.(liveMessage);
|
||||
historyDeferred.resolve([retainedMessage]);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toEqual([
|
||||
{ type: "history", messages: [retainedMessage] },
|
||||
{ type: "message", message: liveMessage },
|
||||
]),
|
||||
);
|
||||
|
||||
observer.unsubscribe();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { onboarding, serverSettings, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import {
|
||||
claimOnboardingAsync,
|
||||
getOnboardingClaimTokenFromCookieHeader,
|
||||
isOnboardingClaimValidAsync,
|
||||
onboardingClaimCookieName,
|
||||
onboardingClaimSettingKey,
|
||||
} from "../../onboarding-claim";
|
||||
|
||||
describe("onboarding claim", () => {
|
||||
it("stores only a token hash and validates the claimed browser", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
|
||||
const result = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 });
|
||||
expect(result.status).toBe("issued");
|
||||
if (result.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
const row = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, onboardingClaimSettingKey),
|
||||
});
|
||||
expect(row?.value).not.toContain(result.token);
|
||||
expect(await isOnboardingClaimValidAsync(db, result.token, 5_000)).toBe(true);
|
||||
expect(await isOnboardingClaimValidAsync(db, "not-the-token", 5_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let another browser replace a live claim", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const issued = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 10_000 });
|
||||
if (issued.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
expect(await claimOnboardingAsync(db, { now: 2_000 })).toEqual({
|
||||
status: "locked",
|
||||
expiresAt: 11_000,
|
||||
});
|
||||
expect(await claimOnboardingAsync(db, { currentToken: issued.token, now: 2_000 })).toEqual({
|
||||
status: "active",
|
||||
token: issued.token,
|
||||
expiresAt: 11_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a new claim after expiry and invalidates the old token", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
const first = await claimOnboardingAsync(db, { now: 1_000, ttlMs: 100 });
|
||||
if (first.status !== "issued") throw new Error("Expected an issued claim");
|
||||
|
||||
const second = await claimOnboardingAsync(db, { now: 1_101, ttlMs: 100 });
|
||||
if (second.status !== "issued") throw new Error("Expected a replacement claim");
|
||||
expect(second.token).not.toBe(first.token);
|
||||
expect(await isOnboardingClaimValidAsync(db, first.token, 1_102)).toBe(false);
|
||||
expect(await isOnboardingClaimValidAsync(db, second.token, 1_102)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses claims after setup finishes", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
|
||||
await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "finished" });
|
||||
});
|
||||
|
||||
it("requires an administrator to rotate claims after a user exists", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "setup", previousStep: "user" });
|
||||
await db.insert(users).values({ id: "owner", name: "owner" });
|
||||
await expect(claimOnboardingAsync(db)).resolves.toEqual({ status: "forbidden" });
|
||||
await expect(claimOnboardingAsync(db, { force: true })).resolves.toMatchObject({ status: "issued" });
|
||||
});
|
||||
|
||||
it("extracts only the exact claim cookie", () => {
|
||||
expect(
|
||||
getOnboardingClaimTokenFromCookieHeader(`theme=dark; ${onboardingClaimCookieName}=secret-token; other=x`),
|
||||
).toBe("secret-token");
|
||||
expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}=secret%2Dtoken`)).toBe("secret-token");
|
||||
expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}=%E0%A4%A`)).toBeUndefined();
|
||||
expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}-copy=wrong`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { stringify } from "superjson";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, sectionCollapseStates, sections, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { sectionRouter } from "../section/section-router";
|
||||
|
||||
const userId = createId();
|
||||
const session = {
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const boardId = createId();
|
||||
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: createId(),
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
return {
|
||||
boardId,
|
||||
db,
|
||||
caller: sectionRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe("changeCollapsed", () => {
|
||||
it("rejects a root section", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
|
||||
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
|
||||
});
|
||||
|
||||
it("creates and updates collapse state for an enabled container", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "container",
|
||||
options: stringify({ collapsible: true }),
|
||||
});
|
||||
|
||||
await caller.changeCollapsed({ sectionId, collapsed: true });
|
||||
await caller.changeCollapsed({ sectionId, collapsed: false });
|
||||
|
||||
const states = await db.query.sectionCollapseStates.findMany({
|
||||
where: eq(sectionCollapseStates.sectionId, sectionId),
|
||||
});
|
||||
expect(states).toHaveLength(1);
|
||||
expect(states[0]?.collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a container when collapsing is disabled", async () => {
|
||||
const { boardId, caller, db } = await createCallerAsync();
|
||||
const sectionId = createId();
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "container",
|
||||
});
|
||||
|
||||
await expect(caller.changeCollapsed({ sectionId, collapsed: true })).rejects.toThrow("Section cannot be collapsed");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, serverSettings } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||
@@ -79,16 +80,39 @@ describe("saveSettings", () => {
|
||||
});
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "analytics")).toStrictEqual([
|
||||
{
|
||||
settingKey: "analytics",
|
||||
value: stringify({
|
||||
enableGeneral: true,
|
||||
instanceId: null,
|
||||
lastSuccessfulSnapshotAt: null,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("analytics settings cannot overwrite the installation ID or snapshot timestamp", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const analyticsSettings = {
|
||||
enableGeneral: true,
|
||||
instanceId: createId(),
|
||||
lastSuccessfulSnapshotAt: "2026-09-25T00:00:00.000Z",
|
||||
};
|
||||
await db.insert(serverSettings).values({ settingKey: "analytics", value: stringify(analyticsSettings) });
|
||||
|
||||
await expect(
|
||||
caller.saveSettings({
|
||||
settingsKey: "analytics",
|
||||
value: { enableGeneral: false, lastSuccessfulSnapshotAt: null },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
await caller.saveSettings({ settingsKey: "analytics", value: { enableGeneral: false } });
|
||||
const saved = await db.query.serverSettings.findFirst({ where: eq(serverSettings.settingKey, "analytics") });
|
||||
expect(saved?.value).toBe(stringify({ ...analyticsSettings, enableGeneral: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("board settings API", () => {
|
||||
@@ -124,7 +148,7 @@ describe("board settings API", () => {
|
||||
forceDisableStatus: true,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
@@ -155,7 +179,7 @@ describe("board settings API", () => {
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
expect(dbSettings.filter(({ settingKey }) => settingKey === "board")).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify(result),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { invites, onboarding, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { claimOnboardingAsync } from "../../onboarding-claim";
|
||||
import { userRouter } from "../user";
|
||||
|
||||
const defaultOwnerId = createId();
|
||||
@@ -40,11 +41,12 @@ vi.mock("@homarr/auth/env", () => {
|
||||
describe("initUser should initialize the first user", () => {
|
||||
it("should create a user if none exists", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -64,11 +66,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should not create a user if the password and confirmPassword do not match", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
@@ -83,11 +86,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it.each([["aB2%"], ["short"]])("should reject passwords shorter than 8 characters for '%s'", async (password) => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
@@ -102,11 +106,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should accept passwords without complexity requirements", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -126,11 +131,12 @@ describe("initUser should initialize the first user", () => {
|
||||
|
||||
it("should accept passwords with special characters like LoveHomarr<3", async () => {
|
||||
const db = createDb();
|
||||
await createOnboardingStepAsync(db, "user");
|
||||
const onboardingClaimToken = await createOnboardingStepAsync(db, "user");
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
onboardingClaimToken,
|
||||
});
|
||||
|
||||
await caller.initUser({
|
||||
@@ -439,4 +445,7 @@ const createOnboardingStepAsync = async (db: Database, step: OnboardingStep) =>
|
||||
id: createId(),
|
||||
step,
|
||||
});
|
||||
const claim = await claimOnboardingAsync(db);
|
||||
if (claim.status !== "issued") throw new Error("Expected an onboarding claim");
|
||||
return claim.token;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { calendarRouter } from "../../widgets/calendar";
|
||||
|
||||
const { getDataAsync, handler } = vi.hoisted(() => {
|
||||
const getDataAsync = vi.fn();
|
||||
return { getDataAsync, handler: vi.fn(() => ({ getDataAsync })) };
|
||||
});
|
||||
|
||||
vi.mock("@homarr/request-handler/calendar", () => ({
|
||||
calendarMonthRequestHandler: { handler },
|
||||
}));
|
||||
|
||||
const createCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "radarr",
|
||||
name: "Radarr",
|
||||
url: "https://radarr.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
const session = {
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
return { caller: calendarRouter.createCaller({ db, deviceType: undefined, session }), integrationId };
|
||||
};
|
||||
|
||||
const createEvent = (title: string, releaseType?: "digitalRelease" | "physicalRelease") => ({
|
||||
title,
|
||||
subTitle: null,
|
||||
description: null,
|
||||
startDate: new Date("2025-05-10T12:00:00.000Z"),
|
||||
endDate: null,
|
||||
image: null,
|
||||
location: null,
|
||||
indicatorColor: "blue",
|
||||
links: [],
|
||||
...(releaseType ? { metadata: { type: "radarr" as const, releaseType } } : {}),
|
||||
});
|
||||
|
||||
describe("calendarRouter.findAllEvents", () => {
|
||||
test("uses human month numbers and filters Radarr release types", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
getDataAsync.mockResolvedValueOnce({
|
||||
data: [
|
||||
createEvent("Digital", "digitalRelease"),
|
||||
createEvent("Physical", "physicalRelease"),
|
||||
createEvent("Sonarr episode"),
|
||||
],
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const result = await caller.findAllEvents({
|
||||
integrationIds: [integrationId],
|
||||
year: 2025,
|
||||
month: 5,
|
||||
releaseType: ["digitalRelease"],
|
||||
showUnmonitored: true,
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: integrationId }), {
|
||||
year: 2025,
|
||||
month: 5,
|
||||
releaseType: ["digitalRelease"],
|
||||
showUnmonitored: true,
|
||||
});
|
||||
expect(result[0]?.events.map((event) => event.title)).toEqual(["Digital", "Sonarr episode"]);
|
||||
});
|
||||
|
||||
test("rejects zero-based and out-of-range months", async () => {
|
||||
const { caller } = await createCallerAsync();
|
||||
await expect(
|
||||
caller.findAllEvents({
|
||||
integrationIds: [],
|
||||
year: 2025,
|
||||
month: 0,
|
||||
releaseType: [],
|
||||
showUnmonitored: true,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("rejects the query when every selected calendar fails", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
getDataAsync.mockRejectedValueOnce(new Error("remote calendar unavailable"));
|
||||
|
||||
await expect(
|
||||
caller.findAllEvents({
|
||||
integrationIds: [integrationId],
|
||||
year: 2025,
|
||||
month: 5,
|
||||
releaseType: ["inCinemas", "digitalRelease", "physicalRelease"],
|
||||
showUnmonitored: true,
|
||||
}),
|
||||
).rejects.toThrow("All integration queries failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { stringify as stringifySuperJson } from "superjson";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boardUserPermissions, boards, customWidgetDefinitions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
|
||||
invalidateResponseCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-executor", () => ({
|
||||
executeCustomWidgetRequest: mocks.executeRequest,
|
||||
invalidateCustomWidgetResponseCache: mocks.invalidateResponseCache,
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
import { customApiRouter } from "../../widgets/custom-api";
|
||||
|
||||
const definition: HomarrCustomWidgetV2 = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Router test widget",
|
||||
sources: {
|
||||
default: {
|
||||
name: "API",
|
||||
baseUrl: "https://example.com",
|
||||
networkScope: "public",
|
||||
auth: "none",
|
||||
},
|
||||
},
|
||||
requests: {
|
||||
"load-status": {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/status",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "load",
|
||||
},
|
||||
status: {
|
||||
source: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
path: "/status/{param:name}",
|
||||
auth: "inherit",
|
||||
permission: "view",
|
||||
trigger: "manual",
|
||||
},
|
||||
toggle: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
path: "/toggle",
|
||||
body: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
permission: "modify",
|
||||
trigger: "manual",
|
||||
},
|
||||
delete: {
|
||||
source: "default",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
path: "/resource",
|
||||
auth: "inherit",
|
||||
permission: "full",
|
||||
trigger: "manual",
|
||||
confirmation: { title: "Delete resource", message: "Delete this resource?", destructive: true },
|
||||
},
|
||||
},
|
||||
options: {},
|
||||
template: "<Text>{data['load-status']?.value}</Text>",
|
||||
};
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
async function setup(
|
||||
isPublic = true,
|
||||
customDefinition: HomarrCustomWidgetV2 = definition,
|
||||
configuration: Record<string, unknown> = {},
|
||||
) {
|
||||
const db = createDb();
|
||||
const ownerId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
const definitionId = createId();
|
||||
await db.insert(users).values({ id: ownerId });
|
||||
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic });
|
||||
await db.insert(customWidgetDefinitions).values({
|
||||
id: definitionId,
|
||||
...serializeCustomWidgetDefinition(customDefinition),
|
||||
creatorId: ownerId,
|
||||
});
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "customApi",
|
||||
options: stringifySuperJson({ definitionId, configuration, configurationVersion: 1 }),
|
||||
});
|
||||
return { db, ownerId, boardId, itemId, definitionId };
|
||||
}
|
||||
|
||||
describe("Custom JSX v2 board router", () => {
|
||||
beforeEach(() => {
|
||||
mocks.executeRequest.mockClear();
|
||||
mocks.invalidateResponseCache.mockClear();
|
||||
});
|
||||
|
||||
test("refreshes the server response cache for a placed widget", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await caller.refresh({ itemId });
|
||||
|
||||
expect(mocks.invalidateResponseCache).toHaveBeenCalledWith([
|
||||
expect.stringMatching(new RegExp(`^custom-jsx:${itemId}:[a-f0-9]{16}:$`, "u")),
|
||||
]);
|
||||
});
|
||||
|
||||
test("loads placed widgets and public queries for anonymous visitors on public boards", async () => {
|
||||
const { db, itemId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).resolves.toMatchObject({
|
||||
template: definition.template,
|
||||
data: { "load-status": { value: 42 } },
|
||||
status: { "load-status": { ok: true } },
|
||||
});
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true, data: { value: 42 } }),
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "missing", params: {} })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(mocks.executeRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("uses current defaults when an updated option no longer accepts the stored value", async () => {
|
||||
const updatedDefinition: HomarrCustomWidgetV2 = {
|
||||
...definition,
|
||||
options: {
|
||||
limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 },
|
||||
},
|
||||
template: "<Text>{options.limit}</Text>",
|
||||
};
|
||||
const { db, itemId } = await setup(true, updatedDefinition, { limit: "5", removed: true });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await expect(caller.getData({ itemId })).resolves.toMatchObject({ options: { limit: 5 } });
|
||||
});
|
||||
|
||||
test("hides private boards and disabled definitions before executing a request", async () => {
|
||||
const privateSetup = await setup(false);
|
||||
const anonymous = customApiRouter.createCaller({
|
||||
db: privateSetup.db,
|
||||
deviceType: undefined,
|
||||
session: null,
|
||||
});
|
||||
await expect(
|
||||
anonymous.queryRequest({ itemId: privateSetup.itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(anonymous.getData({ itemId: privateSetup.itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
|
||||
const enabledSetup = await setup();
|
||||
await enabledSetup.db
|
||||
.update(customWidgetDefinitions)
|
||||
.set({ enabled: false })
|
||||
.where(eq(customWidgetDefinitions.id, enabledSetup.definitionId));
|
||||
const publicCaller = customApiRouter.createCaller({ db: enabledSetup.db, deviceType: undefined, session: null });
|
||||
await expect(publicCaller.getData({ itemId: enabledSetup.itemId })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
publicCaller.queryRequest({ itemId: enabledSetup.itemId, requestId: "status", params: { name: "homarr" } }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
const ownerCaller = customApiRouter.createCaller({
|
||||
db: enabledSetup.db,
|
||||
deviceType: undefined,
|
||||
session: createSession(enabledSetup.ownerId),
|
||||
});
|
||||
await expect(
|
||||
ownerCaller.executeAction({ itemId: enabledSetup.itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects request-kind and parameter mismatches", async () => {
|
||||
const { db, itemId, ownerId } = await setup();
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(caller.queryRequest({ itemId, requestId: "toggle", params: { enabled: true } })).rejects.toMatchObject(
|
||||
{ code: "NOT_FOUND" },
|
||||
);
|
||||
await expect(caller.queryRequest({ itemId, requestId: "status", params: {} })).rejects.toThrow();
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enforces modify permission and renders an authorized action body", async () => {
|
||||
const { db, itemId, boardId } = await setup(false);
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
|
||||
await expect(
|
||||
caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
await expect(caller.executeAction({ itemId, requestId: "toggle", params: { enabled: true } })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
expect(mocks.executeRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "POST", body: '{"enabled":true}' }),
|
||||
);
|
||||
});
|
||||
|
||||
test("requires confirmation and full permission for DELETE actions", async () => {
|
||||
const { db, itemId, boardId, ownerId } = await setup(false);
|
||||
const owner = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
await expect(owner.executeAction({ itemId, requestId: "delete", params: {} })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
});
|
||||
|
||||
const editorId = createId();
|
||||
await db.insert(users).values({ id: editorId });
|
||||
await db.insert(boardUserPermissions).values({ boardId, userId: editorId, permission: "modify" });
|
||||
const editor = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(editorId) });
|
||||
await expect(
|
||||
editor.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(owner.executeAction({ itemId, requestId: "delete", params: {}, confirmed: true })).resolves.toEqual(
|
||||
expect.objectContaining({ ok: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { immichRouter } from "../../widgets/immich";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
albumsHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/immich", () => ({
|
||||
immichAlbumRequestHandler: { handler: vi.fn() },
|
||||
immichStatsRequestHandler: { handler: vi.fn() },
|
||||
immichAlbumsRequestHandler: {
|
||||
handler: (_integration: unknown, input: unknown) => {
|
||||
mocks.albumsHandler(input);
|
||||
return { getDataAsync: async () => ({ data: [], timestamp: new Date() }) };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCallerAsync = async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "immich",
|
||||
name: "Immich",
|
||||
url: "https://immich.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
|
||||
|
||||
return {
|
||||
integrationId,
|
||||
caller: immichRouter.createCaller({ db: db as Database, deviceType: undefined, session: createSession(userId) }),
|
||||
};
|
||||
};
|
||||
|
||||
describe("immichRouter.getAlbums", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
test("passes a validated limit to the cached request handler", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
|
||||
await caller.getAlbums({ integrationId, limit: 50 });
|
||||
|
||||
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: 50 });
|
||||
await expect(caller.getAlbums({ integrationId, limit: 0 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
await expect(caller.getAlbums({ integrationId, limit: 501 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
test("keeps the limit optional for unbounded callers", async () => {
|
||||
const { caller, integrationId } = await createCallerAsync();
|
||||
|
||||
await caller.getAlbums({ integrationId });
|
||||
|
||||
expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { optionsRouter } from "../../widgets/options";
|
||||
|
||||
const createSession = (userId: string, isAdmin = false): Session => ({
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: isAdmin ? ["admin"] : [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
async function setup(kind: "customApi" | "weather") {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(boards).values({ id: boardId, name: createId(), creatorId: userId });
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind,
|
||||
options: SuperJSON.stringify(
|
||||
kind === "customApi"
|
||||
? { definitionId: "definition-1", refreshInterval: 30, configuration: { server: "primary" } }
|
||||
: { location: "Paris" },
|
||||
),
|
||||
});
|
||||
return { db, userId, boardId, itemId };
|
||||
}
|
||||
|
||||
describe("saveItemOptions Custom Widget access", () => {
|
||||
test("rejects direct Custom Widget reconfiguration by a non-admin board modifier", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({
|
||||
boardId,
|
||||
itemId,
|
||||
newOptions: {
|
||||
definitionId: "definition-2",
|
||||
refreshInterval: 1,
|
||||
configuration: { server: "attacker" },
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({
|
||||
definitionId: "definition-1",
|
||||
refreshInterval: 30,
|
||||
configuration: { server: "primary" },
|
||||
});
|
||||
});
|
||||
|
||||
test("allows a Homarr admin to reconfigure a Custom Widget placement", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId, true) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({ boardId, itemId, newOptions: { refreshInterval: 60 } }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
expect(SuperJSON.parse(stored?.options ?? "")).toMatchObject({ refreshInterval: 60 });
|
||||
});
|
||||
|
||||
test("allows normalized missing defaults without treating them as reconfiguration", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("customApi");
|
||||
const stored = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
const options = SuperJSON.parse<Record<string, unknown>>(stored?.options ?? "");
|
||||
delete options.configuration;
|
||||
delete options.configurationVersion;
|
||||
await db
|
||||
.update(items)
|
||||
.set({ options: SuperJSON.stringify(options) })
|
||||
.where(eq(items.id, itemId));
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({
|
||||
boardId,
|
||||
itemId,
|
||||
newOptions: { configuration: {}, configurationVersion: 1 },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves ordinary board-modifier option updates for other widgets", async () => {
|
||||
const { db, userId, boardId, itemId } = await setup("weather");
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(userId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({ boardId, itemId, newOptions: { location: "Lyon" } }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,616 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type {
|
||||
MediaRequest,
|
||||
MediaRequestStats,
|
||||
SpeedtestTrackerDashboardData,
|
||||
TracearrDashboardData,
|
||||
TraefikDashboardData,
|
||||
UpsSummary,
|
||||
UptimeKumaDashboardData,
|
||||
} from "@homarr/integrations/types";
|
||||
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
|
||||
|
||||
import { calendarRouter } from "../../widgets/calendar";
|
||||
import { dnsHoleRouter } from "../../widgets/dns-hole";
|
||||
import { downloadsRouter } from "../../widgets/downloads";
|
||||
import { beszelRouter } from "../../widgets/beszel";
|
||||
import { firewallRouter } from "../../widgets/firewall";
|
||||
import { healthMonitoringRouter } from "../../widgets/health-monitoring";
|
||||
import { indexerManagerRouter } from "../../widgets/indexer-manager";
|
||||
import { mediaOrganizerRouter } from "../../widgets/media-organizer";
|
||||
import { mediaRequestsRouter } from "../../widgets/media-requests";
|
||||
import { mediaServerRouter } from "../../widgets/media-server";
|
||||
import { networkControllerRouter } from "../../widgets/network-controller";
|
||||
import { notificationsRouter } from "../../widgets/notifications";
|
||||
import { speedtestTrackerRouter } from "../../widgets/speedtest-tracker";
|
||||
import { tracearrRouter } from "../../widgets/tracearr";
|
||||
import { traefikRouter } from "../../widgets/traefik";
|
||||
import { upsRouter } from "../../widgets/ups";
|
||||
import { uptimeKumaRouter } from "../../widgets/uptime-kuma";
|
||||
import { vpnRouter } from "../../widgets/vpn";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.SKIP_ENV_VALIDATION = "true";
|
||||
});
|
||||
|
||||
const createHandler = <T>(integration: { name: string }, data: T) => ({
|
||||
getDataAsync: async () => {
|
||||
if (integration.name === "Offline") {
|
||||
throw new Error(
|
||||
"GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body",
|
||||
);
|
||||
}
|
||||
return { data, timestamp: new Date() };
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("@homarr/request-handler/calendar", () => ({
|
||||
calendarMonthRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-server", () => ({
|
||||
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/dns-hole", () => ({
|
||||
dnsHoleRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
status: "enabled",
|
||||
domainsBeingBlocked: 10,
|
||||
adsBlockedToday: 2,
|
||||
adsBlockedTodayPercentage: 20,
|
||||
dnsQueriesToday: 10,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/downloads", () => ({
|
||||
downloadClientRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
items: [],
|
||||
status: { paused: false, rates: { down: 0, up: 0 }, types: ["torrent"] },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/health-monitoring", () => ({
|
||||
systemInfoRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
version: "1.0",
|
||||
cpuModelName: "CPU",
|
||||
cpuUtilization: 10,
|
||||
memUsedInBytes: 1,
|
||||
memAvailableInBytes: 2,
|
||||
uptime: 1,
|
||||
network: null,
|
||||
loadAverage: null,
|
||||
rebootRequired: false,
|
||||
availablePkgUpdates: 0,
|
||||
cpuTemp: undefined,
|
||||
fileSystem: [],
|
||||
smart: [],
|
||||
gpu: [],
|
||||
}),
|
||||
},
|
||||
clusterInfoRequestHandler: { handler: () => createHandler({ name: "Online" }, {}) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/network-controller", () => ({
|
||||
networkControllerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
wanStatus: "enabled",
|
||||
www: { status: "enabled", latency: 5, ping: 6, uptime: 99 },
|
||||
wifi: { status: "enabled", users: 2, guests: 1 },
|
||||
lan: { status: "enabled", users: 3, guests: 0 },
|
||||
vpn: { status: "enabled", users: 1 },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-organizer", () => ({
|
||||
mediaOrganizerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/beszel", () => ({
|
||||
beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/firewall", () => ({
|
||||
firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) },
|
||||
firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
firewallMemoryRequestHandler: {
|
||||
handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }),
|
||||
},
|
||||
firewallVersionRequestHandler: {
|
||||
handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/indexer-manager", () => ({
|
||||
indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/notifications", () => ({
|
||||
notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/vpn", () => ({
|
||||
vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) },
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/tracearr", () => ({
|
||||
tracearrRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
stats: { activeStreams: 0, totalUsers: 1, totalSessions: 1, recentViolations: 0, timestamp: "now" },
|
||||
streams: {
|
||||
data: [],
|
||||
summary: { total: 0, transcodes: 0, directStreams: 0, directPlays: 0, totalBitrate: "0", byServer: [] },
|
||||
},
|
||||
violations: null,
|
||||
recentActivity: null,
|
||||
} satisfies TracearrDashboardData),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/speedtest-tracker", () => ({
|
||||
speedtestTrackerRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
latestResult: null,
|
||||
stats: null,
|
||||
recentResults: [],
|
||||
} satisfies SpeedtestTrackerDashboardData),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/ups", () => ({
|
||||
upsSummariesRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, [
|
||||
{
|
||||
id: "healthy-ups",
|
||||
name: "Healthy UPS",
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
serial: null,
|
||||
status: "online",
|
||||
batteryCharge: 100,
|
||||
batteryRuntime: 3600,
|
||||
batteryVoltage: null,
|
||||
load: 10,
|
||||
inputVoltage: null,
|
||||
outputVoltage: null,
|
||||
power: null,
|
||||
temperature: null,
|
||||
} satisfies UpsSummary,
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/traefik", () => {
|
||||
const emptySummary = { total: 0, enabled: 0, warnings: 0, errors: 0 };
|
||||
return {
|
||||
traefikRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
version: "3.0.0",
|
||||
entryPoints: [],
|
||||
resources: [],
|
||||
failedEndpoints: [],
|
||||
http: { routers: emptySummary, services: emptySummary, middlewares: emptySummary },
|
||||
tcp: { routers: emptySummary, services: emptySummary, middlewares: emptySummary },
|
||||
udp: { routers: emptySummary, services: emptySummary },
|
||||
} satisfies TraefikDashboardData),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/request-handler/uptime-kuma", () => ({
|
||||
uptimeKumaRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
totalMonitors: 1,
|
||||
upCount: 1,
|
||||
downCount: 0,
|
||||
pausedCount: 0,
|
||||
averageUptimePercent: 100,
|
||||
monitors: [{ id: 1, name: "Healthy monitor", status: "up", uptimePercent24h: 100 }],
|
||||
} satisfies UptimeKumaDashboardData),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
|
||||
return {
|
||||
...actual,
|
||||
mediaRequestListRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, [
|
||||
{
|
||||
id: 42,
|
||||
name: "Healthy request",
|
||||
type: "movie",
|
||||
backdropImageUrl: "https://example.com/backdrop.jpg",
|
||||
posterImagePath: "https://example.com/poster.jpg",
|
||||
href: "https://example.com/request/42",
|
||||
createdAt: new Date("2026-08-01T12:00:00Z"),
|
||||
status: "pending",
|
||||
availability: "requested",
|
||||
} satisfies MediaRequest,
|
||||
]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/request-handler/media-request-stats", () => ({
|
||||
mediaRequestStatsRequestHandler: {
|
||||
handler: (integration: { name: string }) =>
|
||||
createHandler(integration, {
|
||||
stats: { total: 0, movie: 0, tv: 0, pending: 0, approved: 0, declined: 0, processing: 0, available: 0 },
|
||||
users: [
|
||||
{
|
||||
id: 7,
|
||||
displayName: "Healthy user",
|
||||
avatar: "https://example.com/avatar.jpg",
|
||||
requestCount: 3,
|
||||
link: "https://example.com/user/7",
|
||||
},
|
||||
],
|
||||
} satisfies MediaRequestStats),
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const setupAsync = async (kind: IntegrationKind, options: { allOffline?: boolean } = {}) => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const onlineId = createId();
|
||||
const offlineId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
await db.insert(integrations).values([
|
||||
{ id: onlineId, kind, name: options.allOffline ? "Offline" : "Online", url: "https://online.example.com" },
|
||||
{ id: offlineId, kind, name: "Offline", url: "https://offline.example.com" },
|
||||
]);
|
||||
await db.insert(integrationUserPermissions).values([
|
||||
{ integrationId: onlineId, userId, permission: "use" },
|
||||
{ integrationId: offlineId, userId, permission: "use" },
|
||||
]);
|
||||
return {
|
||||
db: db as Database,
|
||||
session: createSession(userId),
|
||||
integrationIds: [onlineId, offlineId],
|
||||
onlineId,
|
||||
offlineId,
|
||||
};
|
||||
};
|
||||
|
||||
type IntegrationSetup = Awaited<ReturnType<typeof setupAsync>>;
|
||||
|
||||
const dashboardPartialFailureCases = [
|
||||
{
|
||||
kind: "tracearr" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await tracearrRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "speedtestTracker" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await speedtestTrackerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "peaNut" as const,
|
||||
payloadKey: "summaries",
|
||||
fallbackValue: [],
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await upsRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSummaries({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "traefik" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await traefikRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "uptimeKuma" as const,
|
||||
payloadKey: "dashboard",
|
||||
fallbackValue: null,
|
||||
run: async (setup: IntegrationSetup) =>
|
||||
await uptimeKumaRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getDashboard({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("partial integration failures", () => {
|
||||
test("calendar and media server retain a failed integration record", async () => {
|
||||
const calendar = await setupAsync("sonarr");
|
||||
const calendarResults = await calendarRouter
|
||||
.createCaller({ db: calendar.db, deviceType: undefined, session: calendar.session })
|
||||
.findAllEvents({
|
||||
integrationIds: calendar.integrationIds,
|
||||
year: 2026,
|
||||
month: 7,
|
||||
releaseType: [],
|
||||
showUnmonitored: false,
|
||||
});
|
||||
expect(calendarResults).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integration: expect.objectContaining({ id: calendar.offlineId }),
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(calendarResults)).not.toContain("secret");
|
||||
|
||||
const mediaServer = await setupAsync("plex");
|
||||
const streamResults = await mediaServerRouter
|
||||
.createCaller({ db: mediaServer.db, deviceType: undefined, session: mediaServer.session })
|
||||
.getCurrentStreams({ integrationIds: mediaServer.integrationIds, showOnlyPlaying: false });
|
||||
expect(streamResults).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: mediaServer.offlineId,
|
||||
sessions: [],
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(streamResults)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("DNS widgets retain source ownership for partial failures", async () => {
|
||||
const dnsHole = await setupAsync("piHole");
|
||||
const results = await dnsHoleRouter
|
||||
.createCaller({ db: dnsHole.db, deviceType: undefined, session: dnsHole.session })
|
||||
.summary({ integrationIds: dnsHole.integrationIds });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: dnsHole.onlineId,
|
||||
summary: expect.objectContaining({ status: "enabled" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: dnsHole.offlineId,
|
||||
integrationName: "Offline",
|
||||
summary: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("downloads retain source ownership for partial failures", async () => {
|
||||
const downloads = await setupAsync("qBittorrent");
|
||||
const results = await downloadsRouter
|
||||
.createCaller({ db: downloads.db, deviceType: undefined, session: downloads.session })
|
||||
.getJobsAndStatuses({ integrationIds: downloads.integrationIds, limitPerIntegration: 50 });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ integrationId: downloads.onlineId, data: expect.objectContaining({ items: [] }) }),
|
||||
expect.objectContaining({
|
||||
integrationId: downloads.offlineId,
|
||||
integrationName: "Offline",
|
||||
data: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("system health retains source ownership for partial failures", async () => {
|
||||
const health = await setupAsync("glances");
|
||||
const results = await healthMonitoringRouter
|
||||
.createCaller({ db: health.db, deviceType: undefined, session: health.session })
|
||||
.getSystemHealthStatus({ integrationIds: health.integrationIds });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: health.onlineId,
|
||||
healthInfo: expect.objectContaining({ version: "1.0" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: health.offlineId,
|
||||
integrationName: "Offline",
|
||||
healthInfo: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("network controllers retain source ownership for partial failures", async () => {
|
||||
const network = await setupAsync("unifiController");
|
||||
const results = await networkControllerRouter
|
||||
.createCaller({ db: network.db, deviceType: undefined, session: network.session })
|
||||
.summary({ integrationIds: network.integrationIds });
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: network.onlineId,
|
||||
summary: expect.objectContaining({ wanStatus: "enabled" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: network.offlineId,
|
||||
integrationName: "Offline",
|
||||
summary: null,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("dashboard widgets preserve healthy sources and expose sanitized failed-source envelopes", async () => {
|
||||
for (const testCase of dashboardPartialFailureCases) {
|
||||
const setup = await setupAsync(testCase.kind);
|
||||
const results = await testCase.run(setup);
|
||||
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integrationId: setup.onlineId,
|
||||
integrationName: "Online",
|
||||
[testCase.payloadKey]: expect.anything(),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
integrationId: setup.offlineId,
|
||||
integrationName: "Offline",
|
||||
[testCase.payloadKey]: testCase.fallbackValue,
|
||||
error: "INTEGRATION_REQUEST_FAILED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(results)).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard widgets reject total integration failure instead of returning fallback-only data", async () => {
|
||||
for (const testCase of dashboardPartialFailureCases) {
|
||||
const setup = await setupAsync(testCase.kind, { allOffline: true });
|
||||
|
||||
await expect(testCase.run(setup)).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "All integration queries failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("media request responses expose failed integrations without dropping successful data", async () => {
|
||||
const mediaRequests = await setupAsync("overseerr");
|
||||
const caller = mediaRequestsRouter.createCaller({
|
||||
db: mediaRequests.db,
|
||||
deviceType: undefined,
|
||||
session: mediaRequests.session,
|
||||
});
|
||||
|
||||
const latest = await caller.getLatestRequests({
|
||||
integrationIds: mediaRequests.integrationIds,
|
||||
statuses: ["pending"],
|
||||
recentDays: 0,
|
||||
});
|
||||
expect(latest.requests).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 42,
|
||||
name: "Healthy request",
|
||||
integrationId: mediaRequests.onlineId,
|
||||
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
|
||||
}),
|
||||
]);
|
||||
expect(latest.failedIntegrations).toEqual([
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
|
||||
]);
|
||||
expect(JSON.stringify(latest)).not.toContain("secret");
|
||||
|
||||
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
|
||||
expect(stats.failedIntegrations).toEqual([
|
||||
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
|
||||
]);
|
||||
expect(JSON.stringify(stats)).not.toContain("secret");
|
||||
expect(stats.stats).toHaveLength(1);
|
||||
expect(stats.users).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
displayName: "Healthy user",
|
||||
integration: expect.objectContaining({ id: mediaRequests.onlineId }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("aggregate widget payloads never expose sensitive partial-failure details", async () => {
|
||||
const cases = [
|
||||
{
|
||||
kind: "sonarr" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await mediaOrganizerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getData({ integrationIds: setup.integrationIds, pageSize: 10 }),
|
||||
},
|
||||
{
|
||||
kind: "beszel" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await beszelRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSystems({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "opnsense" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await firewallRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getFirewallCpuStatus({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "prowlarr" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await indexerManagerRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getIndexersStatus({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "ntfy" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await notificationsRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getNotifications({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
{
|
||||
kind: "gluetun" as const,
|
||||
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
|
||||
await vpnRouter
|
||||
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
|
||||
.getSummaries({ integrationIds: setup.integrationIds }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const setup = await setupAsync(testCase.kind);
|
||||
const payload = JSON.stringify(await testCase.run(setup));
|
||||
expect(payload).toContain("INTEGRATION_REQUEST_FAILED");
|
||||
expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => {
|
||||
});
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({ code: "UNAUTHORIZED" }),
|
||||
);
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
@@ -17,7 +17,18 @@ import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../../../widgets/src/definition";
|
||||
import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed";
|
||||
import { canAccessAllFeedsAsync, restrictUrlsAsync, rssFeedRouter } from "../../widgets/rssFeed";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, getFeed: vi.fn() }));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => mocks.logger }));
|
||||
vi.mock("@homarr/request-handler/rss-feeds", () => ({
|
||||
rssFeedsRequestHandler: {
|
||||
handler: (input: unknown) => ({ getDataAsync: async () => await mocks.getFeed(input) }),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||
user: {
|
||||
@@ -29,6 +40,53 @@ const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
|
||||
});
|
||||
|
||||
describe("rssFeedRouter", () => {
|
||||
describe("getFeeds", () => {
|
||||
test("redacts feed secrets from logs and returns a generic error when every feed fails", async () => {
|
||||
const feedUrl = "https://user:password@example.com/feed?token=secret";
|
||||
mocks.getFeed.mockRejectedValue(new Error(`Failed to fetch ${feedUrl}`));
|
||||
const caller = rssFeedRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createSession(["board-create"]),
|
||||
});
|
||||
|
||||
await expect(caller.getFeeds({ urls: [feedUrl], maximumAmountPosts: 5 })).rejects.toMatchObject({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "All RSS feed requests failed",
|
||||
});
|
||||
expect(mocks.logger.warn).toHaveBeenCalledWith("RSS feed fetch failed", {
|
||||
feedIndex: 0,
|
||||
origin: "https://example.com",
|
||||
errorType: "Error",
|
||||
errorMessage: "Failed to fetch [redacted URL]",
|
||||
});
|
||||
const logged = JSON.stringify(mocks.logger.warn.mock.calls);
|
||||
expect(logged).not.toContain("password");
|
||||
expect(logged).not.toContain("token");
|
||||
expect(logged).not.toContain("secret");
|
||||
});
|
||||
|
||||
test("returns successful entries and only a failure count when some feeds fail", async () => {
|
||||
const failedUrl = "https://user:password@example.com/feed?token=secret";
|
||||
mocks.getFeed.mockImplementation(async (input: { url: string }) => {
|
||||
if (input.url === failedUrl) throw new Error(`Failed to fetch ${failedUrl}`);
|
||||
return { data: { entries: [{ id: "entry" }] } };
|
||||
});
|
||||
const caller = rssFeedRouter.createCaller({
|
||||
db: createDb(),
|
||||
deviceType: undefined,
|
||||
session: createSession(["board-create"]),
|
||||
});
|
||||
|
||||
const result = await caller.getFeeds({ urls: ["https://example.com/feed", failedUrl], maximumAmountPosts: 5 });
|
||||
|
||||
expect(result).toEqual({ entries: [{ id: "entry" }], failedFeedCount: 1 });
|
||||
expect(JSON.stringify(result)).not.toContain("password");
|
||||
expect(JSON.stringify(result)).not.toContain("token");
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessAllFeedsAsync", () => {
|
||||
test("should return false for unauthenticated users", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { parse, stringify } from "superjson";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { boardRouter } from "../../board";
|
||||
import { optionsRouter } from "../../widgets/options";
|
||||
import { timetableRouter } from "../../widgets/timetable";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup }));
|
||||
|
||||
vi.mock("@homarr/request-handler/timetable", () => ({
|
||||
timetableGetTimetableRequestHandler: {
|
||||
handler: (input: unknown) => ({
|
||||
getDataAsync: async () => {
|
||||
mocks.getTimetable(input);
|
||||
return { data: { stationId: "station", timestamp: new Date(0), entries: [] } };
|
||||
},
|
||||
}),
|
||||
},
|
||||
timetableSearchStationsRequestHandler: {
|
||||
handler: (input: unknown) => ({
|
||||
getDataAsync: async () => {
|
||||
mocks.searchStations(input);
|
||||
return { data: [] };
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
|
||||
});
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: { id: userId, permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCaller = (db: Database, session: Session | null) =>
|
||||
timetableRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
const createTimetableItemAsync = async (
|
||||
db: Database,
|
||||
{ baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean },
|
||||
) => {
|
||||
const ownerId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
await db.insert(users).values({ id: ownerId });
|
||||
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic });
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "timetable",
|
||||
options: stringify({ baseUrl }),
|
||||
});
|
||||
return { boardId, itemId, ownerId };
|
||||
};
|
||||
|
||||
describe("timetableRouter source authorization", () => {
|
||||
it("allows itemless setup only for the fixed default provider", async () => {
|
||||
const caller = createCaller(createDb(), null);
|
||||
|
||||
await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]);
|
||||
await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid timetable URLs as bad requests", async () => {
|
||||
const caller = createCaller(createDb(), null);
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects a persisted private endpoint at runtime even through a viewable timetable item", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl });
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.getTimetable).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("rejects a persisted endpoint when its board is not viewable", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("allows board modifiers to search an unsaved public endpoint", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
|
||||
expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true });
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://127.0.0.1:8080",
|
||||
"http://timetable.internal:8080",
|
||||
"http://[::1]:8080",
|
||||
"http://[::ffff:127.0.0.1]:8080",
|
||||
"https://[fd00::1]:8443",
|
||||
"https://192.88.99.1",
|
||||
"http://[64:ff9b::7f00:1]:8080",
|
||||
"http://[2002:7f00:1::]:8080",
|
||||
])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an RFC 2765 IPv4-translated loopback address", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an unsaved public hostname that resolves to a private address", async () => {
|
||||
mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(
|
||||
caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the exact saved private endpoint at runtime", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-resolves and pins a saved public endpoint for searches and timetable requests", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
const pinnedAddresses = [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "93.184.216.35", family: 4 },
|
||||
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 },
|
||||
];
|
||||
mocks.lookup.mockResolvedValue([pinnedAddresses[2], pinnedAddresses[1], pinnedAddresses[0]]);
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
|
||||
|
||||
expect(mocks.lookup).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
|
||||
expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl, pinnedAddresses }));
|
||||
});
|
||||
|
||||
it("rejects a saved public hostname when it resolves to a private address at request time", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
mocks.lookup.mockResolvedValue([{ address: "10.0.0.8", family: 4 }]);
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
|
||||
const caller = createCaller(db, createSession(ownerId));
|
||||
|
||||
await expect(caller.searchStations({ baseUrl, boardId, itemId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
await expect(caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
expect(mocks.getTimetable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks changing a saved timetable to a private endpoint through item options", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = optionsRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(
|
||||
caller.saveItemOptions({ boardId, itemId, newOptions: { baseUrl: "http://127.0.0.1:8080" } }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
const item = await db.query.items.findFirst({ where: eq(items.id, itemId) });
|
||||
expect(parse<Record<string, unknown>>(item?.options ?? "{}")).toMatchObject({ baseUrl: "https://search.ch" });
|
||||
});
|
||||
|
||||
it("blocks changing a saved timetable to a private endpoint through a board save", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(
|
||||
caller.saveBoard({
|
||||
id: boardId,
|
||||
sections: [],
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "timetable",
|
||||
options: { baseUrl: "http://127.0.0.1:8080" },
|
||||
integrationIds: [],
|
||||
layouts: [],
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("blocks adding a timetable with a private endpoint", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch",
|
||||
isPublic: false,
|
||||
});
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) });
|
||||
|
||||
await expect(
|
||||
caller.addItem({
|
||||
boardId,
|
||||
kind: "timetable",
|
||||
options: { baseUrl: "http://127.0.0.1:8080" },
|
||||
integrationIds: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects an item ID that does not belong to the requested board", async () => {
|
||||
const db = createDb();
|
||||
const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
|
||||
const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(second.ownerId)).searchStations({
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
boardId: second.boardId,
|
||||
itemId: first.itemId,
|
||||
query: "Bern",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a changed private endpoint for an existing item", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createCaller(db, createSession(ownerId)).searchStations({
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
boardId,
|
||||
itemId,
|
||||
query: "Bern",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.searchStations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a changed public endpoint for an existing item and pins its address", async () => {
|
||||
const db = createDb();
|
||||
const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "http://timetable.internal:8080",
|
||||
isPublic: false,
|
||||
});
|
||||
const baseUrl = "https://timetable.example.com";
|
||||
|
||||
await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
|
||||
|
||||
expect(mocks.searchStations).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed saved widget options as server data corruption", async () => {
|
||||
const db = createDb();
|
||||
const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" });
|
||||
await db.update(items).set({ options: "{" }).where(eq(items.id, itemId));
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({
|
||||
baseUrl: "https://search.ch",
|
||||
itemId,
|
||||
stationId: "station",
|
||||
limit: 10,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
|
||||
});
|
||||
|
||||
it("reports an invalid saved timetable URL as server data corruption", async () => {
|
||||
const db = createDb();
|
||||
const { itemId } = await createTimetableItemAsync(db, {
|
||||
baseUrl: "https://search.ch?redirect=/admin",
|
||||
});
|
||||
|
||||
await expect(
|
||||
createCaller(db, null).getTimetable({
|
||||
baseUrl: "https://search.ch",
|
||||
itemId,
|
||||
stationId: "station",
|
||||
limit: 10,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
|
||||
});
|
||||
|
||||
it("does not treat public board view access as configuration access", async () => {
|
||||
const db = createDb();
|
||||
const baseUrl = "http://timetable.internal:8080";
|
||||
const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl });
|
||||
|
||||
await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user