fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 01:20:27 +02:00
1 parent 576cc0b418
commit 07770e862c
44 files changed
+3003 -1000

No files matched your search

+108 -37
View File
@@ -8,7 +8,7 @@ import type { GridAlgorithmItem } from "@homarr/common";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
import { and, asc, eq, gt, gte, handleTransactionsAsync, inArray, isNull, like, lt, not, or, sql } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
@@ -84,6 +84,7 @@ interface BoardItemPlacementRectangle {
}
const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12;
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
@@ -261,6 +262,11 @@ export const boardRouter = createTRPCRouter({
breakpoint: true,
role: true,
},
orderBy: (layout, { desc, sql }) => [
sql`CASE WHEN ${layout.role} = 'base' THEN 0 ELSE 1 END`,
desc(layout.breakpoint),
],
limit: 1,
},
sections: {
columns: {
@@ -268,26 +274,21 @@ export const boardRouter = createTRPCRouter({
kind: true,
xOffset: true,
},
with: {
layouts: {
columns: {
layoutId: true,
parentSectionId: true,
xOffset: true,
yOffset: true,
width: true,
height: true,
},
},
},
where: eq(sections.kind, "empty"),
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
},
items: {
columns: {
id: true,
},
with: {
layouts: {
},
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
});
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
const rootSectionIds = dbBoards.flatMap((board) => board.sections.map((section) => section.id));
const [previewItemLayouts, previewSectionLayouts] =
previewLayoutIds.length > 0 && rootSectionIds.length > 0
? await Promise.all([
ctx.db.query.itemLayouts.findMany({
columns: {
itemId: true,
layoutId: true,
sectionId: true,
xOffset: true,
@@ -295,17 +296,93 @@ export const boardRouter = createTRPCRouter({
width: true,
height: true,
},
},
},
},
},
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
});
where: and(
inArray(itemLayouts.layoutId, previewLayoutIds),
inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
}),
ctx.db.query.sectionLayouts.findMany({
columns: {
sectionId: true,
layoutId: true,
parentSectionId: true,
xOffset: true,
yOffset: true,
width: true,
height: true,
},
with: {
section: {
columns: { kind: true },
},
},
where: and(
inArray(sectionLayouts.layoutId, previewLayoutIds),
inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0),
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0),
),
}),
])
: [[], []];
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, items: boardItems, ...board }) => {
const previewLayout =
boardLayouts.find((layout) => layout.role === "base") ??
boardLayouts.toSorted((first, second) => second.breakpoint - first.breakpoint).at(0);
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, ...board }) => {
const previewLayout = boardLayouts.at(0);
const rootsByLane = new Map<BoardLane, (typeof boardSections)[number]>();
for (const section of boardSections) {
const lane = getRootSectionLane(section.xOffset);
if (!rootsByLane.has(lane)) rootsByLane.set(lane, section);
}
const previewRoots = boardLanes.flatMap((lane) => {
const root = rootsByLane.get(lane);
return root ? [{ id: root.id, kind: "empty" as const, xOffset: root.xOffset, layouts: [] }] : [];
});
const rootColumnCountById = new Map(
boardLanes.flatMap((lane) => {
const root = rootsByLane.get(lane);
return root && previewLayout ? [[root.id, getBoardLaneColumnCount(previewLayout, lane)] as const] : [];
}),
);
const isInsideRootLane = (layout: { sectionId: string; xOffset: number }) => {
const columnCount = rootColumnCountById.get(layout.sectionId);
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
};
const isInsideParentRootLane = (layout: { parentSectionId: string | null; xOffset: number }) => {
if (!layout.parentSectionId) return false;
const columnCount = rootColumnCountById.get(layout.parentSectionId);
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
};
const itemPreview = previewLayout
? previewItemLayouts
.filter((layout) => layout.layoutId === previewLayout.id && isInsideRootLane(layout))
.map((layout) => ({ id: layout.itemId, layouts: [layout] }))
: [];
const containerPreview = previewLayout
? previewSectionLayouts
.filter(
(layout) =>
layout.layoutId === previewLayout.id &&
layout.section.kind === "container" &&
isInsideParentRootLane(layout),
)
.map((layout) => ({
id: layout.sectionId,
kind: "container" as const,
xOffset: null,
layouts: [layout],
}))
: [];
return {
...board,
@@ -314,14 +391,8 @@ export const boardRouter = createTRPCRouter({
preview: previewLayout
? {
layouts: [previewLayout],
sections: boardSections.map((section) => ({
...section,
layouts: section.layouts.filter((layout) => layout.layoutId === previewLayout.id),
})),
items: boardItems.map((item) => ({
...item,
layouts: item.layouts.filter((layout) => layout.layoutId === previewLayout.id),
})),
sections: [...previewRoots, ...containerPreview],
items: itemPreview,
}
: null,
};
+20 -2
View File
@@ -1,3 +1,5 @@
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -193,16 +195,32 @@ export const groupRouter = createTRPCRouter({
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const transitionResult = (await transaction
.update(schema.onboarding)
.set({ previousStep: "group", step: "setup" })
.where(eq(schema.onboarding.step, "group"))) as MySqlRawQueryResult | QueryResult;
const transitionedRows = Array.isArray(transitionResult)
? transitionResult[0].affectedRows
: (transitionResult.rowCount ?? 0);
if (transitionedRows !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
}
await transaction.insert(schema.groups).values(groupRow);
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
await transaction.update(schema.onboarding).set({ previousStep: "group", step: "setup" });
});
},
handleSync(db) {
db.transaction((transaction) => {
const transitionResult = transaction
.update(onboarding)
.set({ previousStep: "group", step: "setup" })
.where(eq(onboarding.step, "group"))
.run();
if (transitionResult.changes !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
}
transaction.insert(groups).values(groupRow).run();
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
transaction.update(onboarding).set({ previousStep: "group", step: "setup" }).run();
});
},
});
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
describe("probeRuntimeCapabilitiesAsync", () => {
afterEach(() => {
vi.useRealTimers();
});
it("reports independently reachable runtime capabilities", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}", { status: 200 }));
await expect(
probeRuntimeCapabilitiesAsync({
kubernetesEnabled: true,
workshopApiUrl: "https://workshop.example.com/",
fetchImpl,
getKubernetesVersionAsync: async () => ({ gitVersion: "v1.34.0" }),
}),
).resolves.toEqual({
kubernetes: { status: "available", detail: "v1.34.0" },
workshop: { status: "available" },
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://workshop.example.com/api/health",
expect.objectContaining({ headers: { Accept: "application/json" } }),
);
});
it("distinguishes disabled and unreachable capabilities", async () => {
await expect(
probeRuntimeCapabilitiesAsync({
kubernetesEnabled: false,
workshopApiUrl: "https://workshop.example.com",
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 })),
}),
).resolves.toEqual({
kubernetes: { status: "disabled" },
workshop: { status: "unavailable" },
});
});
it("aborts a never-resolving Kubernetes request when the probe times out", async () => {
vi.useFakeTimers();
let kubernetesSignal: AbortSignal | undefined;
const getKubernetesVersionAsync = vi.fn(
async (signal: AbortSignal) =>
await new Promise<never>((_, reject) => {
kubernetesSignal = signal;
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
}),
);
const result = probeRuntimeCapabilitiesAsync({
kubernetesEnabled: true,
workshopApiUrl: "https://workshop.example.com",
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 200 })),
getKubernetesVersionAsync,
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(result).resolves.toEqual({
kubernetes: { status: "unavailable" },
workshop: { status: "available" },
});
expect(getKubernetesVersionAsync).toHaveBeenCalledOnce();
expect(kubernetesSignal?.aborted).toBe(true);
});
});
@@ -0,0 +1,80 @@
import type { RequestContext, ResponseContext } from "@kubernetes/client-node";
import { KubernetesClient } from "../kubernetes/kubernetes-client";
export type RuntimeCapability =
| { status: "available"; detail?: string }
| { status: "disabled" }
| { status: "unavailable" };
export interface RuntimeCapabilities {
kubernetes: RuntimeCapability;
workshop: RuntimeCapability;
}
const capabilityProbeTimeoutMs = 5_000;
const withTimeoutAsync = async <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs = capabilityProbeTimeoutMs,
) => {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
const error = new Error("Capability probe timed out");
controller.abort(error);
reject(error);
}, timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
};
export const probeRuntimeCapabilitiesAsync = async ({
kubernetesEnabled,
workshopApiUrl,
fetchImpl = fetch,
getKubernetesVersionAsync = async (signal) =>
await KubernetesClient.getInstance().versionApi.getCode({
middleware: [
{
pre: async (request: RequestContext) => {
request.setSignal(signal);
return request;
},
post: async (response: ResponseContext) => response,
},
],
}),
}: {
kubernetesEnabled: boolean;
workshopApiUrl: string;
fetchImpl?: typeof fetch;
getKubernetesVersionAsync?: (signal: AbortSignal) => Promise<{ gitVersion?: string }>;
}): Promise<RuntimeCapabilities> => {
const kubernetesPromise: Promise<RuntimeCapability> = kubernetesEnabled
? withTimeoutAsync(getKubernetesVersionAsync)
.then((version) => ({ status: "available" as const, detail: version.gitVersion }))
.catch(() => ({ status: "unavailable" as const }))
: Promise.resolve({ status: "disabled" as const });
const workshopPromise: Promise<RuntimeCapability> = withTimeoutAsync(
async (signal) =>
await fetchImpl(`${workshopApiUrl.replace(/\/+$/u, "")}/api/health`, {
headers: { Accept: "application/json" },
signal,
}).then((response) => {
if (!response.ok) throw new Error(`Workshop health returned ${response.status}`);
}),
)
.then(() => ({ status: "available" as const }))
.catch(() => ({ status: "unavailable" as const }));
const [kubernetes, workshop] = await Promise.all([kubernetesPromise, workshopPromise]);
return { kubernetes, workshop };
};
@@ -6,7 +6,7 @@ import type { OnboardingStep } from "@homarr/definitions";
export const nextOnboardingStepAsync = async (db: Database) => {
const { current } = await getOnboardingOrFallbackAsync(db);
if (current !== "start") return;
const nextStep = getNextOnboardingStep();
const nextStep = await getNextOnboardingStepAsync(db);
if (!nextStep) return;
await db.update(onboarding).set({ previousStep: current, step: nextStep });
@@ -22,8 +22,9 @@ export const getOnboardingOrFallbackAsync = async (db: Database) => {
};
};
const getNextOnboardingStep = (): OnboardingStep => {
if (isProviderEnabled("credentials")) return "user";
const getNextOnboardingStepAsync = async (db: Database): Promise<OnboardingStep> => {
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
if (isProviderEnabled("credentials") && !existingUser) return "user";
if (isProviderEnabled("ldap") || isProviderEnabled("oidc")) return "group";
return "setup";
};
+202 -198
View File
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import { z } from "zod/v4";
@@ -25,34 +27,28 @@ import {
serverSettings,
} from "@homarr/db/schema";
import {
defaultWidgetConfigs,
emptySuperJSON,
everyoneGroup,
extractContainerImageName,
getBoardLaneColumnCount,
getDefaultWidgetConfig,
getIconUrl,
getRootSectionLane,
getWidgetIntegrationIssue,
getWidgetKindsForIntegration,
integrationKinds,
integrationSecretKinds,
matchIntegrationKindFromContainer,
rootSectionOffsets,
widgetIntegrationLimits,
} from "@homarr/definitions";
import type { IntegrationKind, OnboardingLayoutPreset, WidgetKind } from "@homarr/definitions";
import {
onboardingCompleteSetupSchema,
onboardingCreateIntegrationSchema,
onboardingDiscoveredAppSchema,
} from "@homarr/validation/onboarding";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { onboardingCompleteSetupSchema } from "@homarr/validation/onboarding";
import { onboardingClaimSettingKey } from "../../onboarding-claim";
import { env } from "../../env";
import { createTRPCRouter, onboardingClaimedProcedure, onboardingProcedure, publicProcedure } from "../../trpc";
import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection";
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
interface PortInfo {
IP?: string;
@@ -125,8 +121,17 @@ const getLayoutPresetColumnCount = (preset: OnboardingLayoutPreset) => {
};
const normalizeLabelBoardName = (name: string) => name.replace(/[^A-Za-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
const defaultAppIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
const runtimeCapabilitySchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("available"), detail: z.string().optional() }),
z.object({ status: z.literal("disabled") }),
z.object({ status: z.literal("unavailable") }),
]);
const runtimeCapabilitiesSchema = z.object({
kubernetes: runtimeCapabilitySchema,
workshop: runtimeCapabilitySchema,
});
export const onboardRouter = createTRPCRouter({
currentStep: publicProcedure.query(async ({ ctx }) => await getOnboardingOrFallbackAsync(ctx.db)),
@@ -139,102 +144,15 @@ export const onboardRouter = createTRPCRouter({
await nextOnboardingStepAsync(ctx.db);
}),
createIntegration: onboardingProcedure
detectRuntimeCapabilities: onboardingProcedure
.requiresStep("setup")
.input(
onboardingCreateIntegrationSchema.extend({
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
kind: zodEnumFromArray(integrationSecretKinds),
value: z.string().nonempty(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
const testResult = await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
}).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
.output(runtimeCapabilitiesSchema)
.query(async () => {
const { env: dockerEnv } = await import("@homarr/docker/env");
return await probeRuntimeCapabilitiesAsync({
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES,
workshopApiUrl: env.WORKSHOP_API_URL ?? env.HOMARR_WEBSITE_URL,
});
if (!testResult.success) return { error: mapTestConnectionError(testResult.error) };
const integrationId = input.sourceId ? stableOnboardingId("integration", input.sourceId) : createId();
const existingIntegration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, integrationId),
});
const appId =
existingIntegration?.appId ?? (input.sourceId ? stableOnboardingId("app", input.sourceId) : createId());
const existingApp = await ctx.db.query.apps.findFirst({ where: eq(apps.id, appId) });
const appRow = {
id: appId,
name: input.name,
iconUrl: input.iconUrl ?? getIconUrl(input.kind),
href: input.url,
pingUrl: input.pingUrl ?? input.url,
description: input.description ?? null,
};
const integrationRow = {
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
};
const secretRows = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
}));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (existingApp) {
await transaction.update(schema.apps).set(appRow).where(eq(schema.apps.id, appId));
} else {
await transaction.insert(schema.apps).values(appRow);
}
if (existingIntegration) {
await transaction
.update(schema.integrations)
.set(integrationRow)
.where(eq(schema.integrations.id, integrationId));
await transaction
.delete(schema.integrationSecrets)
.where(eq(schema.integrationSecrets.integrationId, integrationId));
} else {
await transaction.insert(schema.integrations).values(integrationRow);
}
if (secretRows.length > 0) await transaction.insert(schema.integrationSecrets).values(secretRows);
});
},
handleSync(db) {
db.transaction((transaction) => {
if (existingApp) {
transaction.update(apps).set(appRow).where(eq(apps.id, appId)).run();
} else {
transaction.insert(apps).values(appRow).run();
}
if (existingIntegration) {
transaction.update(integrations).set(integrationRow).where(eq(integrations.id, integrationId)).run();
transaction.delete(integrationSecrets).where(eq(integrationSecrets.integrationId, integrationId)).run();
} else {
transaction.insert(integrations).values(integrationRow).run();
}
if (secretRows.length > 0) transaction.insert(integrationSecrets).values(secretRows).run();
});
},
});
return { id: integrationId, appId };
}),
discoverDockerServices: onboardingProcedure.requiresStep("setup").query(async ({ ctx }) => {
@@ -372,72 +290,6 @@ export const onboardRouter = createTRPCRouter({
}
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("setup")
.input(z.array(onboardingDiscoveredAppSchema).max(128))
.mutation(async ({ ctx, input }) => {
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
const planned = [] as Array<{
id: string;
sourceId?: string;
existing: boolean;
row: typeof apps.$inferInsert;
}>;
const seenIds = new Set<string>();
for (const app of input) {
const stableId = app.sourceId ? stableOnboardingId("app", app.sourceId) : undefined;
const existing = stableId
? await ctx.db.query.apps.findFirst({ where: eq(apps.id, stableId) })
: app.href
? await ctx.db.query.apps.findFirst({ where: eq(apps.href, app.href) })
: undefined;
const id = existing?.id ?? stableId ?? createId();
if (seenIds.has(id)) continue;
seenIds.add(id);
planned.push({
id,
sourceId: app.sourceId,
existing: Boolean(existing),
row: {
id,
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
href: app.href,
pingUrl: app.pingUrl ?? app.href,
description: app.description ?? null,
},
});
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
for (const app of planned) {
if (app.existing) {
await transaction.update(schema.apps).set(app.row).where(eq(schema.apps.id, app.id));
} else {
await transaction.insert(schema.apps).values(app.row);
}
}
});
},
handleSync(db) {
db.transaction((transaction) => {
for (const app of planned) {
if (app.existing) {
transaction.update(apps).set(app.row).where(eq(apps.id, app.id)).run();
} else {
transaction.insert(apps).values(app.row).run();
}
}
});
},
});
return { apps: planned.map(({ id, sourceId }) => ({ id, sourceId })) };
}),
completeSetup: onboardingProcedure
.requiresStep("setup")
.input(onboardingCompleteSetupSchema)
@@ -518,20 +370,114 @@ export const onboardRouter = createTRPCRouter({
message: "Select the exact board to configure when more than one board exists.",
});
}
const selectedIntegrations =
await Promise.all(
input.integrations.map(async (integration) => {
const result = await testConnectionAsync({
id: stableOnboardingId("integration", integration.sourceId),
name: integration.name,
url: integration.url,
kind: integration.kind,
secrets: integration.secrets,
}).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
});
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unable to connect to ${integration.name}: ${result.error.message}`,
});
}
}),
);
const plannedIntegrationIds = input.integrations.map((integration) =>
stableOnboardingId("integration", integration.sourceId),
);
const existingPlannedIntegrations =
plannedIntegrationIds.length > 0
? await ctx.db.query.integrations.findMany({ where: inArray(integrations.id, plannedIntegrationIds) })
: [];
const existingIntegrationById = new Map(
existingPlannedIntegrations.map((integration) => [integration.id, integration]),
);
const integrationPlans = input.integrations.map((integration) => {
const id = stableOnboardingId("integration", integration.sourceId);
const existing = existingIntegrationById.get(id);
const appId = existing?.appId ?? stableOnboardingId("app", integration.sourceId);
return {
id,
existing: existing !== undefined,
row: {
id,
name: integration.name,
url: integration.url,
kind: integration.kind,
appId,
} satisfies typeof integrations.$inferSelect,
appRow: {
id: appId,
name: integration.name,
iconUrl: integration.iconUrl ?? getIconUrl(integration.kind),
href: integration.url,
pingUrl: integration.pingUrl ?? integration.url,
description: integration.description ?? null,
} satisfies typeof apps.$inferSelect,
secretRows: integration.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId: id,
})),
};
});
const plannedAppRows = [
...integrationPlans.map((plan) => plan.appRow),
...input.apps.map(
(app) =>
({
id: stableOnboardingId("app", app.sourceId),
name: app.name,
iconUrl: app.iconUrl ?? defaultAppIcon,
href: app.href,
pingUrl: app.pingUrl ?? app.href,
description: app.description ?? null,
}) satisfies typeof apps.$inferSelect,
),
];
const plannedAppIds = plannedAppRows.map((app) => app.id);
if (new Set(plannedAppIds).size !== plannedAppIds.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Onboarding app source IDs resolve to the same app." });
}
const existingPlannedApps =
plannedAppIds.length > 0 ? await ctx.db.query.apps.findMany({ where: inArray(apps.id, plannedAppIds) }) : [];
const existingPlannedAppIds = new Set(existingPlannedApps.map((app) => app.id));
const appPlans = plannedAppRows.map((row) => ({ id: row.id, existing: existingPlannedAppIds.has(row.id), row }));
const plannedIntegrationIdSet = new Set(plannedIntegrationIds);
const plannedAppIdSet = new Set(plannedAppIds);
if (input.selectedIntegrationIds.some((id) => plannedIntegrationIdSet.has(id))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A new integration cannot also be selected by ID." });
}
if (input.selectedAppIds.some((id) => plannedAppIdSet.has(id))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A new app cannot also be selected by ID." });
}
const persistedSelectedIntegrations =
input.selectedIntegrationIds.length > 0
? await ctx.db.query.integrations.findMany({ where: inArray(integrations.id, input.selectedIntegrationIds) })
: [];
const selectedApps =
const persistedSelectedApps =
input.selectedAppIds.length > 0
? await ctx.db.query.apps.findMany({ where: inArray(apps.id, input.selectedAppIds) })
: [];
if (selectedIntegrations.length !== input.selectedIntegrationIds.length) {
if (persistedSelectedIntegrations.length !== input.selectedIntegrationIds.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected integrations no longer exist." });
}
if (selectedApps.length !== input.selectedAppIds.length) {
if (persistedSelectedApps.length !== input.selectedAppIds.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected apps no longer exist." });
}
const selectedIntegrations = [...persistedSelectedIntegrations, ...integrationPlans.map((plan) => plan.row)];
const selectedApps = [...persistedSelectedApps, ...plannedAppRows];
const selectedDockerSources =
input.selectedDockerSourceIds.length > 0
@@ -571,7 +517,11 @@ export const onboardRouter = createTRPCRouter({
}
targetBoardName = parsedName.data;
}
if (boardList.some((board) => board.id !== targetBoard.id && board.name === targetBoardName)) {
if (
boardList.some(
(board) => board.id !== targetBoard.id && board.name.toLowerCase() === targetBoardName.toLowerCase(),
)
) {
throw new TRPCError({ code: "CONFLICT", message: "A board with this name already exists." });
}
const targetBoardNames = new Set([targetBoard.name, targetBoardName]);
@@ -770,8 +720,8 @@ export const onboardRouter = createTRPCRouter({
}
}
for (const [kind, integrationIdsForWidget] of selectedIntegrationIdsByWidget) {
const config = defaultWidgetConfigByKind.get(kind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(kind);
if (config.skip) continue;
const existing = existingItemsByKind.get(kind);
const limit = widgetIntegrationLimits[kind] ?? Number.POSITIVE_INFINITY;
let unlinkedIntegrationIds = integrationIdsForWidget;
@@ -795,9 +745,9 @@ export const onboardRouter = createTRPCRouter({
if (getWidgetIntegrationIssue(kind, integrationKindsForItem)) continue;
const itemId = placeNewItem(
kind,
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config?.width ?? 2,
config?.height ?? 2,
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config.width,
config.height,
);
newIntegrationItems.push(...integrationIdsForItem.map((integrationId) => ({ itemId, integrationId })));
}
@@ -808,13 +758,13 @@ export const onboardRouter = createTRPCRouter({
for (const kind of input.selectedWidgetKinds.filter((kind) => !selectedLabelWidgetKinds.has(kind))) {
if (existingItemsByKind.has(kind) || newItems.some((item) => item.kind === kind)) continue;
if (getWidgetIntegrationIssue(kind, [])) continue;
const config = defaultWidgetConfigByKind.get(kind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(kind);
if (config.skip) continue;
placeNewItem(
kind,
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config?.width ?? 2,
config?.height ?? 2,
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config.width,
config.height,
);
}
const dockerSourceByAppId = new Map(
@@ -863,13 +813,13 @@ export const onboardRouter = createTRPCRouter({
} else if (existing) {
continue;
}
const config = defaultWidgetConfigByKind.get(service.widgetKind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(service.widgetKind);
if (config.skip) continue;
const itemId = placeNewItem(
service.widgetKind,
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config?.width ?? 2,
config?.height ?? 2,
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config.width,
config.height,
);
if (integration) newIntegrationItems.push({ itemId, integrationId: integration.id });
}
@@ -942,13 +892,13 @@ export const onboardRouter = createTRPCRouter({
continue;
}
const config = defaultWidgetConfigByKind.get(service.widgetKind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(service.widgetKind);
if (config.skip) continue;
groupedItemPlans.push({
kind: service.widgetKind,
options: config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
width: config?.width ?? 2,
height: config?.height ?? 2,
options: config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
width: config.width,
height: config.height,
integrationId: integration?.id,
});
}
@@ -1053,6 +1003,36 @@ export const onboardRouter = createTRPCRouter({
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const transitionResult = (await transaction
.update(schema.onboarding)
.set({ previousStep: "setup", step: "finish" })
.where(eq(schema.onboarding.step, "setup"))) as MySqlRawQueryResult | QueryResult;
const transitionedRows = Array.isArray(transitionResult)
? transitionResult[0].affectedRows
: (transitionResult.rowCount ?? 0);
if (transitionedRows !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "Onboarding setup was already completed." });
}
for (const plan of appPlans) {
if (plan.existing) {
await transaction.update(schema.apps).set(plan.row).where(eq(schema.apps.id, plan.id));
} else {
await transaction.insert(schema.apps).values(plan.row);
}
}
for (const plan of integrationPlans) {
if (plan.existing) {
await transaction.update(schema.integrations).set(plan.row).where(eq(schema.integrations.id, plan.id));
await transaction
.delete(schema.integrationSecrets)
.where(eq(schema.integrationSecrets.integrationId, plan.id));
} else {
await transaction.insert(schema.integrations).values(plan.row);
}
if (plan.secretRows.length > 0) {
await transaction.insert(schema.integrationSecrets).values(plan.secretRows);
}
}
const boardValues = {
name: targetBoardName,
primaryColor: input.board.primaryColor,
@@ -1113,11 +1093,36 @@ export const onboardRouter = createTRPCRouter({
await transaction
.delete(schema.serverSettings)
.where(eq(schema.serverSettings.settingKey, onboardingClaimSettingKey));
await transaction.update(schema.onboarding).set({ previousStep: "setup", step: "finish" });
});
},
handleSync(db) {
db.transaction((transaction) => {
const transitionResult = transaction
.update(onboarding)
.set({ previousStep: "setup", step: "finish" })
.where(eq(onboarding.step, "setup"))
.run();
if (transitionResult.changes !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "Onboarding setup was already completed." });
}
for (const plan of appPlans) {
if (plan.existing) {
transaction.update(apps).set(plan.row).where(eq(apps.id, plan.id)).run();
} else {
transaction.insert(apps).values(plan.row).run();
}
}
for (const plan of integrationPlans) {
if (plan.existing) {
transaction.update(integrations).set(plan.row).where(eq(integrations.id, plan.id)).run();
transaction.delete(integrationSecrets).where(eq(integrationSecrets.integrationId, plan.id)).run();
} else {
transaction.insert(integrations).values(plan.row).run();
}
if (plan.secretRows.length > 0) {
transaction.insert(integrationSecrets).values(plan.secretRows).run();
}
}
const boardValues = {
name: targetBoardName,
primaryColor: input.board.primaryColor,
@@ -1176,7 +1181,6 @@ export const onboardRouter = createTRPCRouter({
.where(eq(groups.name, everyoneGroup))
.run();
transaction.delete(serverSettings).where(eq(serverSettings.settingKey, onboardingClaimSettingKey)).run();
transaction.update(onboarding).set({ previousStep: "setup", step: "finish" }).run();
});
},
});
+151 -15
View File
@@ -330,7 +330,11 @@ describe("getManageOverview", () => {
const baseLayoutId = createId();
const mobileLayoutId = createId();
const rootSectionId = createId();
const containerSectionId = createId();
const itemId = createId();
const mobileItemId = createId();
const offscreenItemId = createId();
const nestedItemId = createId();
await db.insert(users).values({ id: defaultCreatorId });
await db.insert(boards).values([
@@ -356,23 +360,75 @@ describe("getManageOverview", () => {
role: "base",
},
]);
await db.insert(sections).values({
id: rootSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
});
await db.insert(items).values({ id: itemId, boardId, kind: "clock" });
await db.insert(itemLayouts).values({
itemId,
sectionId: rootSectionId,
await db.insert(sections).values([
{
id: rootSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerSectionId,
boardId,
kind: "container",
xOffset: null,
yOffset: null,
},
]);
await db.insert(sectionLayouts).values({
sectionId: containerSectionId,
parentSectionId: rootSectionId,
layoutId: baseLayoutId,
xOffset: 2,
yOffset: 1,
width: 4,
height: 2,
xOffset: 0,
yOffset: 4,
width: 6,
height: 3,
});
await db.insert(items).values([
{ id: itemId, boardId, kind: "clock" },
{ id: mobileItemId, boardId, kind: "clock" },
{ id: offscreenItemId, boardId, kind: "clock" },
{ id: nestedItemId, boardId, kind: "clock" },
]);
await db.insert(itemLayouts).values([
{
itemId,
sectionId: rootSectionId,
layoutId: baseLayoutId,
xOffset: 2,
yOffset: 1,
width: 4,
height: 2,
},
{
itemId: mobileItemId,
sectionId: rootSectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 3,
height: 1,
},
{
itemId: offscreenItemId,
sectionId: rootSectionId,
layoutId: baseLayoutId,
xOffset: 0,
yOffset: 12,
width: 2,
height: 1,
},
{
itemId: nestedItemId,
sectionId: containerSectionId,
layoutId: baseLayoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 1,
},
]);
const result = await caller.getManageOverview();
@@ -388,9 +444,89 @@ describe("getManageOverview", () => {
layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }],
},
],
sections: [
{ id: rootSectionId, kind: "empty" },
{
id: containerSectionId,
kind: "container",
layouts: [{ layoutId: baseLayoutId, parentSectionId: rootSectionId, yOffset: 4 }],
},
],
},
});
expect(result[0]?.preview?.layouts).toHaveLength(1);
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId]);
expect(result[0]?.preview?.sections).toHaveLength(2);
});
test("uses the highest-breakpoint layout when a board has no base layout", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const boardId = createId();
const mobileLayoutId = createId();
const tabletLayoutId = createId();
const rootSectionId = createId();
const mobileItemId = createId();
const tabletItemId = createId();
await db.insert(users).values({ id: defaultCreatorId });
await db.insert(boards).values({ id: boardId, name: "fallback-overview", creatorId: defaultCreatorId });
await db.insert(layouts).values([
{
id: mobileLayoutId,
name: "Mobile",
boardId,
columnCount: 3,
breakpoint: 0,
role: "mobile",
},
{
id: tabletLayoutId,
name: "Tablet",
boardId,
columnCount: 8,
breakpoint: 600,
role: "custom",
},
]);
await db.insert(sections).values({
id: rootSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
});
await db.insert(items).values([
{ id: mobileItemId, boardId, kind: "clock" },
{ id: tabletItemId, boardId, kind: "clock" },
]);
await db.insert(itemLayouts).values([
{
itemId: mobileItemId,
sectionId: rootSectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 3,
height: 1,
},
{
itemId: tabletItemId,
sectionId: rootSectionId,
layoutId: tabletLayoutId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 1,
},
]);
const result = await caller.getManageOverview();
expect(result[0]?.preview?.layouts).toEqual([
expect.objectContaining({ id: tabletLayoutId, breakpoint: 600, role: "custom" }),
]);
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([tabletItemId]);
});
});
@@ -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" });
});
});
+171 -36
View File
@@ -20,12 +20,14 @@ import {
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { WidgetKind } from "@homarr/definitions";
import type { OnboardingCompleteSetupInput } from "@homarr/validation/onboarding";
import { claimOnboardingAsync, isOnboardingClaimValidAsync } from "../../onboarding-claim";
import * as integrationConnection from "../integration/integration-test-connection";
const listDiscoveredContainersAsync = vi.hoisted(() => vi.fn());
const createIdMock = vi.hoisted(() => vi.fn());
const isProviderEnabled = vi.hoisted(() => vi.fn<(provider: string) => boolean>(() => false));
const stableAppIdForSource = (sourceId: string) => {
const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32);
return `onboarding_app_${digest}`;
@@ -35,7 +37,7 @@ const stableIntegrationIdForSource = (sourceId: string) => {
return `onboarding_integration_${digest}`;
};
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled }));
vi.mock("@homarr/common", async (importOriginal) => ({
...(await importOriginal<typeof import("@homarr/common")>()),
createId: createIdMock,
@@ -51,7 +53,7 @@ import { normalizeOnboardingStep } from "../onboard/onboard-queries";
const createCaller = (db: ReturnType<typeof createDb>, onboardingClaimToken?: string) =>
onboardRouter.createCaller({ db, deviceType: undefined, session: null, onboardingClaimToken });
const completionInput = (boardId: string, name = "Configured-board") => ({
const completionInput = (boardId: string, name = "Configured-board"): OnboardingCompleteSetupInput => ({
server: {
defaultLocale: "en" as const,
defaultColorScheme: "dark" as const,
@@ -69,6 +71,8 @@ const completionInput = (boardId: string, name = "Configured-board") => ({
},
selectedIntegrationIds: [] as string[],
selectedAppIds: [] as string[],
integrations: [],
apps: [],
selectedDockerSourceIds: [] as string[],
selectedWidgetKinds: [] as WidgetKind[],
});
@@ -188,6 +192,63 @@ describe("onboard.completeSetup", () => {
expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(false);
});
it("rejects a stale completion after another submission claims the setup transition", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
await seedBoardAsync(db, { boardId: "target" });
const input = completionInput("target");
input.selectedDockerSourceIds = ["docker:missing"];
input.selectedWidgetKinds = ["clock"];
let releaseDiscovery: () => void = () => undefined;
const discoveryGate = new Promise<void>((resolve) => {
releaseDiscovery = resolve;
});
listDiscoveredContainersAsync.mockImplementation(async () => {
await discoveryGate;
return { hosts: [], services: [] };
});
const submission = Promise.allSettled([createCaller(db, claimToken).completeSetup(input)]);
try {
await vi.waitFor(() => expect(listDiscoveredContainersAsync).toHaveBeenCalledOnce());
await db.update(onboarding).set({ previousStep: "setup", step: "finish" });
} finally {
releaseDiscovery();
}
const [result] = await submission;
expect(result).toMatchObject({
status: "rejected",
reason: expect.objectContaining({ message: "Onboarding setup was already completed." }),
});
expect(await db.query.items.findMany({ where: eq(items.kind, "clock") })).toHaveLength(0);
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" });
});
it("uses canonical widget sizes for automatically generated integration widgets", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
await seedBoardAsync(db, { boardId: "target" });
await db.insert(integrations).values({
id: "sonarr",
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr",
});
const input = completionInput("target");
input.selectedIntegrationIds = ["sonarr"];
await createCaller(db, claimToken).completeSetup(input);
const mediaMissing = await db.query.items.findFirst({ where: eq(items.kind, "mediaMissing") });
expect(mediaMissing).toBeDefined();
expect(
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
).toMatchObject({ width: 4, height: 3 });
});
it("requires an exact board id when more than one board exists", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
@@ -206,6 +267,21 @@ describe("onboard.completeSetup", () => {
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
});
it("rejects a board rename that only differs by case from another board", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
await seedBoardAsync(db, { boardId: "target" });
await seedBoardAsync(db, { boardId: "existing" });
await db.update(boards).set({ name: "Media" }).where(eq(boards.id, "existing"));
await expect(createCaller(db, claimToken).completeSetup(completionInput("target", "media"))).rejects.toThrow(
"A board with this name already exists.",
);
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
});
it("creates a complete first board when setup resumes without any boards", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
@@ -334,11 +410,12 @@ describe("onboard.completeSetup", () => {
expect(await db.query.items.findMany()).toEqual([expect.objectContaining({ boardId: "target", kind: "clock" })]);
});
it("persists discovery metadata on created apps and integration companion apps", async () => {
it("creates selected apps, integrations, secrets, and companion apps in completeSetup", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
const caller = createCaller(db, claimToken);
const appResult = await caller.createAppsFromDiscovery([
await seedBoardAsync(db, { boardId: "target" });
const input = completionInput("target");
input.apps = [
{
sourceId: "docker:good:status",
name: "Status",
@@ -347,18 +424,9 @@ describe("onboard.completeSetup", () => {
iconUrl: "https://icons.example/status.svg",
description: "Internal status page",
},
]);
const createdApp = appResult.apps[0];
assert(createdApp);
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdApp.id) })).toMatchObject({
pingUrl: "http://status:3001/health",
iconUrl: "https://icons.example/status.svg",
description: "Internal status page",
});
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
try {
const integrationResult = await caller.createIntegration({
];
input.integrations = [
{
sourceId: "docker:good:sonarr",
name: "Sonarr",
url: "http://sonarr:8989",
@@ -367,45 +435,85 @@ describe("onboard.completeSetup", () => {
pingUrl: "http://sonarr:8989/ping",
iconUrl: "https://icons.example/sonarr.svg",
description: "TV automation",
},
];
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
try {
await createCaller(db, claimToken).completeSetup(input);
expect(
await db.query.apps.findFirst({ where: eq(apps.id, stableAppIdForSource("docker:good:status")) }),
).toMatchObject({
href: "http://status:3001",
pingUrl: "http://status:3001/health",
iconUrl: "https://icons.example/status.svg",
description: "Internal status page",
});
const createdAppId = "appId" in integrationResult ? integrationResult.appId : undefined;
expect(createdAppId).toBeDefined();
if (!createdAppId) throw new Error("Expected integration creation to succeed");
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdAppId) })).toMatchObject({
expect(
await db.query.apps.findFirst({ where: eq(apps.id, stableAppIdForSource("docker:good:sonarr")) }),
).toMatchObject({
href: "http://sonarr:8989",
pingUrl: "http://sonarr:8989/ping",
iconUrl: "https://icons.example/sonarr.svg",
description: "TV automation",
});
expect(
await db.query.integrations.findFirst({
where: eq(integrations.id, stableIntegrationIdForSource("docker:good:sonarr")),
}),
).toMatchObject({
appId: stableAppIdForSource("docker:good:sonarr"),
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr",
});
const savedSecrets = await db.query.integrationSecrets.findMany({
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource("docker:good:sonarr")),
});
expect(savedSecrets).toEqual([expect.objectContaining({ kind: "apiKey" })]);
expect(savedSecrets[0]?.value).not.toBe("secret");
expect(await db.query.items.findMany({ where: eq(items.kind, "app") })).toHaveLength(2);
} finally {
testConnection.mockRestore();
}
});
it("reuses a manual integration and companion app across retries", async () => {
it("rolls draft records back with board writes and reuses stable IDs on retry", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
const caller = createCaller(db, claimToken);
await seedBoardAsync(db, { boardId: "target", itemId: "generated-id" });
const sourceId = "manual:sonarr";
const createInput = {
sourceId,
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr" as const,
secrets: [{ kind: "apiKey" as const, value: "secret" }],
};
const input = completionInput("target", "Retried-board");
input.integrations = [
{
sourceId,
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr",
secrets: [{ kind: "apiKey", value: "secret" }],
},
];
createIdMock.mockReturnValue("generated-id");
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
try {
const first = await caller.createIntegration(createInput);
const second = await caller.createIntegration(createInput);
await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow();
expect(await db.query.integrations.findMany()).toHaveLength(0);
expect(await db.query.apps.findMany()).toHaveLength(0);
expect(await db.query.integrationSecrets.findMany()).toHaveLength(0);
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
expect(first).toEqual(second);
expect(first).toEqual({
let nextRetryId = 0;
createIdMock.mockImplementation(() => `retry-${++nextRetryId}`);
await createCaller(db, claimToken).completeSetup(input);
expect(await db.query.integrations.findMany()).toHaveLength(1);
expect(await db.query.apps.findMany()).toHaveLength(1);
expect(await db.query.integrations.findFirst()).toMatchObject({
id: stableIntegrationIdForSource(sourceId),
appId: stableAppIdForSource(sourceId),
});
expect(await db.query.integrations.findMany()).toHaveLength(1);
expect(await db.query.apps.findMany()).toHaveLength(1);
expect(
await db.query.integrationSecrets.findMany({
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource(sourceId)),
@@ -938,6 +1046,10 @@ describe("onboard.completeSetup", () => {
});
describe("onboard.nextStep", () => {
beforeEach(() => {
isProviderEnabled.mockReturnValue(false);
});
it("advances welcome exactly once", async () => {
const db = createDb();
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
@@ -950,6 +1062,29 @@ describe("onboard.nextStep", () => {
await expect(caller.nextStep()).rejects.toThrow("The welcome step is already complete.");
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
});
it("skips duplicate credential account creation when an administrator resumes a legacy step", async () => {
isProviderEnabled.mockImplementation((provider: string) => provider === "credentials");
const db = createDb();
await db.insert(onboarding).values({ id: "onboarding", step: "import" as never });
await db.insert(users).values({ id: "existing-admin", name: "admin", provider: "credentials" });
const { nextOnboardingStepAsync } = await import("../onboard/onboard-queries");
await nextOnboardingStepAsync(db);
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
});
it("still requires credential account creation on a fresh installation", async () => {
isProviderEnabled.mockImplementation((provider: string) => provider === "credentials");
const db = createDb();
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
const { nextOnboardingStepAsync } = await import("../onboard/onboard-queries");
await nextOnboardingStepAsync(db);
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "user", previousStep: "start" });
});
});
describe("normalizeOnboardingStep", () => {