fix: harden onboarding completion
This commit is contained in:
1 parent
576cc0b418
commit
07770e862c
44 files changed
+3003
-1000
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { onboardingCreateIntegrationSchema, onboardingDiscoveredAppSchema } from "./onboarding";
|
||||
import {
|
||||
onboardingCompleteSetupSchema,
|
||||
onboardingCreateIntegrationSchema,
|
||||
onboardingDiscoveredAppSchema,
|
||||
} from "./onboarding";
|
||||
|
||||
describe("onboarding URL validation", () => {
|
||||
test.each(["javascript:alert(1)", "data:text/html,hello", "file:///etc/passwd"])(
|
||||
@@ -53,4 +57,25 @@ describe("onboarding URL validation", () => {
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("requires every app submitted to completeSetup to have an HTTP(S) href", () => {
|
||||
const input = {
|
||||
server: { defaultLocale: "en", defaultColorScheme: "dark" },
|
||||
board: {
|
||||
name: "dashboard",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
},
|
||||
apps: [{ sourceId: "docker:status", name: "Status", href: null, iconUrl: null }],
|
||||
};
|
||||
|
||||
expect(onboardingCompleteSetupSchema.safeParse(input).success).toBe(false);
|
||||
expect(
|
||||
onboardingCompleteSetupSchema.safeParse({
|
||||
...input,
|
||||
apps: [{ ...input.apps[0], href: "https://status.example.com" }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { colorSchemes, onboardingLayoutPresets, widgetKinds } from "@homarr/definitions";
|
||||
import {
|
||||
colorSchemes,
|
||||
integrationKinds,
|
||||
integrationSecretKinds,
|
||||
onboardingLayoutPresets,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { supportedLanguages } from "@homarr/translation/languages";
|
||||
|
||||
import { boardNameSchema } from "./board";
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
|
||||
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
||||
const httpUrlSchema = z.url().refine((value) => {
|
||||
@@ -14,44 +21,30 @@ const uniqueIdsSchema = z
|
||||
.array(z.string().trim().min(1).max(255))
|
||||
.max(128)
|
||||
.refine((ids) => new Set(ids).size === ids.length, { message: "IDs must be unique" });
|
||||
|
||||
export const onboardingCompleteSetupSchema = z.object({
|
||||
server: z.object({
|
||||
defaultLocale: z.enum(supportedLanguages),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
analyticsEnabled: z.boolean().optional(),
|
||||
}),
|
||||
board: z.object({
|
||||
id: z.string().trim().min(1).max(255).optional(),
|
||||
name: boardNameSchema,
|
||||
primaryColor: hexColorSchema,
|
||||
secondaryColor: hexColorSchema,
|
||||
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
|
||||
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
|
||||
leftSidebar: z.boolean().default(false),
|
||||
rightSidebar: z.boolean().default(false),
|
||||
}),
|
||||
selectedIntegrationIds: uniqueIdsSchema.default([]),
|
||||
selectedAppIds: uniqueIdsSchema.default([]),
|
||||
selectedDockerSourceIds: uniqueIdsSchema.default([]),
|
||||
selectedWidgetKinds: z
|
||||
.array(z.enum(widgetKinds))
|
||||
.max(widgetKinds.length)
|
||||
.refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" })
|
||||
.default([]),
|
||||
});
|
||||
const sourceIdSchema = z.string().trim().min(1).max(512);
|
||||
|
||||
export const onboardingCreateIntegrationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(127),
|
||||
url: httpUrlSchema,
|
||||
sourceId: z.string().trim().min(1).max(512).optional(),
|
||||
sourceId: sourceIdSchema.optional(),
|
||||
iconUrl: z.string().trim().min(1).max(2048).nullable().optional(),
|
||||
description: z.string().trim().max(512).nullable().optional(),
|
||||
pingUrl: httpUrlSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchema.extend({
|
||||
sourceId: sourceIdSchema,
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const onboardingDiscoveredAppSchema = z.object({
|
||||
sourceId: z.string().trim().min(1).max(512).optional(),
|
||||
sourceId: sourceIdSchema.optional(),
|
||||
name: z.string().trim().min(1).max(127),
|
||||
href: httpUrlSchema.nullable(),
|
||||
pingUrl: httpUrlSchema.nullable().optional(),
|
||||
@@ -59,4 +52,62 @@ export const onboardingDiscoveredAppSchema = z.object({
|
||||
description: z.string().trim().max(512).nullable().optional(),
|
||||
});
|
||||
|
||||
export const onboardingAppDraftSchema = onboardingDiscoveredAppSchema.extend({
|
||||
sourceId: sourceIdSchema,
|
||||
href: httpUrlSchema,
|
||||
});
|
||||
|
||||
const integrationDraftsSchema = z
|
||||
.array(onboardingIntegrationDraftSchema)
|
||||
.max(128)
|
||||
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
|
||||
message: "Integration source IDs must be unique",
|
||||
});
|
||||
const appDraftsSchema = z
|
||||
.array(onboardingAppDraftSchema)
|
||||
.max(128)
|
||||
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
|
||||
message: "App source IDs must be unique",
|
||||
});
|
||||
|
||||
export const onboardingCompleteSetupSchema = z
|
||||
.object({
|
||||
server: z.object({
|
||||
defaultLocale: z.enum(supportedLanguages),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
analyticsEnabled: z.boolean().optional(),
|
||||
}),
|
||||
board: z.object({
|
||||
id: z.string().trim().min(1).max(255).optional(),
|
||||
name: boardNameSchema,
|
||||
primaryColor: hexColorSchema,
|
||||
secondaryColor: hexColorSchema,
|
||||
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
|
||||
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
|
||||
leftSidebar: z.boolean().default(false),
|
||||
rightSidebar: z.boolean().default(false),
|
||||
}),
|
||||
selectedIntegrationIds: uniqueIdsSchema.default([]),
|
||||
selectedAppIds: uniqueIdsSchema.default([]),
|
||||
integrations: integrationDraftsSchema.default([]),
|
||||
apps: appDraftsSchema.default([]),
|
||||
selectedDockerSourceIds: uniqueIdsSchema.default([]),
|
||||
selectedWidgetKinds: z
|
||||
.array(z.enum(widgetKinds))
|
||||
.max(widgetKinds.length)
|
||||
.refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" })
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
const integrationSourceIds = new Set(input.integrations.map((integration) => integration.sourceId));
|
||||
for (const [index, app] of input.apps.entries()) {
|
||||
if (!integrationSourceIds.has(app.sourceId)) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "A source ID cannot be used by both an integration and an app",
|
||||
path: ["apps", index, "sourceId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type OnboardingCompleteSetupInput = z.infer<typeof onboardingCompleteSetupSchema>;
|
||||
Reference in new issue
Block a user