fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:12 +02:00
1 parent e41605640b
commit dc71084323
44 files changed
+3003 -1000

No files matched your search

+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", () => {