diff --git a/lib/types/api.ts b/lib/types/api.ts index f9bb0eb8c..3ff1655b0 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -32,6 +32,33 @@ import type {LogLevel, schemaJson} from "../util/settings"; // biome-ignore lint/suspicious/noExplicitAny: API type KeyValue = Record; +export interface OnboardInitData { + page: "form"; + settings: Zigbee2MQTTSettings; + settingsSchema: typeof schemaJson; + devices: { + name: string; + path: Zigbee2MQTTSettings["serial"]["port"]; + adapter?: Zigbee2MQTTSettings["serial"]["adapter"]; + baudRate?: Zigbee2MQTTSettings["serial"]["baudrate"]; + rtscts?: Zigbee2MQTTSettings["serial"]["rtscts"]; + }[]; +} + +export interface OnboardDoneData { + page: "done"; + frontendUrl: string | null; +} + +export interface OnboardFailureData { + page: "failure"; + errors: string[]; +} + +export type OnboardData = OnboardInitData | OnboardDoneData | OnboardFailureData; + +export type OnboardSubmitResponse = {success: true; frontendUrl: string | null} | {success: false; error: string}; + export interface Zigbee2MQTTDeviceOptions { disabled?: boolean; retention?: number; diff --git a/lib/util/onboarding.ts b/lib/util/onboarding.ts index 4e2abf5c7..be860bbd6 100644 --- a/lib/util/onboarding.ts +++ b/lib/util/onboarding.ts @@ -1,398 +1,102 @@ -import {existsSync, mkdirSync} from "node:fs"; +import {existsSync, mkdirSync, writeFileSync} from "node:fs"; +import type {ServerResponse} from "node:http"; import {createServer} from "node:http"; -import {parse} from "node:querystring"; +import path from "node:path"; +import expressStaticGzip from "express-static-gzip"; +import finalhandler from "finalhandler"; +import stringify from "json-stable-stringify-without-jsonify"; +import JSZip from "jszip"; import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery"; +import type {OnboardData, OnboardFailureData, OnboardSubmitResponse, Zigbee2MQTTSettings} from "../types/api"; import data from "./data"; import * as settings from "./settings"; import {YAMLFileException} from "./yaml"; -type OnboardSettings = { - mqtt_base_topic?: string; - mqtt_server?: string; - mqtt_user?: string; - mqtt_password?: string; - serial_port?: string; - serial_adapter?: Settings["serial"]["adapter"]; - serial_baudrate?: string; - serial_rtscts?: "on"; - network_channel?: string; - network_key?: string; - network_pan_id?: string; - network_ext_pan_id?: string; - frontend_enabled?: "on"; - frontend_port?: string; - homeassistant_enabled?: "on"; - log_level?: Settings["advanced"]["log_level"]; +/** same as extension/frontend */ +const FILE_SERVER_OPTIONS: expressStaticGzip.ExpressStaticGzipOptions = { + enableBrotli: true, + serveStatic: { + /* v8 ignore start */ + setHeaders: (res: ServerResponse, path: string): void => { + if (path.endsWith("index.html")) { + res.setHeader("Cache-Control", "no-store"); + } + }, + /* v8 ignore stop */ + }, }; -function escapeHtml(s: string): string { - return s.replace(/[^0-9A-Za-z \-_.]/g, (c) => `&#${c.charCodeAt(0)};`); -} - -function generateHtmlDone(frontendUrl: string | undefined): string { - return ` - - - - - - Zigbee2MQTT Onboarding - - - -
-

Zigbee2MQTT Onboarding

-

Settings saved.

-

Zigbee2MQTT is now starting...

- ${frontendUrl ? `Redirecting to Zigbee2MQTT frontend at ${frontendUrl} in 30 seconds.` : "You can close this page."} -
- ${frontendUrl ? `` : ""} - - -`; -} - -function generateHtmlForm(currentSettings: RecursivePartial, devices: Awaited>): string { - let devicesSelect = ""; - - if (devices.length > 0) { - devicesSelect += '"; - devicesSelect += "Optionally allows to configure coordinator port and type (if known) automatically."; - } else { - devicesSelect = "No device found"; - } - - let generateCheckbox = ""; - - if ( - Array.isArray(currentSettings.advanced?.network_key) || - typeof currentSettings.advanced?.pan_id === "number" || - Array.isArray(currentSettings.advanced?.ext_pan_id) - ) { - generateCheckbox = ` - -`; - } - - /* v8 ignore start */ - return ` - - - - - - Zigbee2MQTT Onboarding - - - -
-

Zigbee2MQTT Onboarding

-

Set the base configuration to start Zigbee2MQTT.

-

Optional fields will either be ignored or fallback to defaults if not set (see appropriate documentation page for more details).

-

If a field is disabled, it means environment variables are being used to override specific values (for example, through the Home Assistant add-on configuration page).

-
-
-
- - ${devicesSelect} -
-
- - - - - - - Can be ignored for networked coordinators (TCP). - - - Can be ignored for networked coordinators (TCP). -
- - https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html - -
-
- - - Optionally set to your closest WiFi channel to pick the best value for "Network channel" below. - - -
-
- ${generateCheckbox} - - - - - - -
- - https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html - -
-
- - - - - - - Optional. Set only if using authentication. - - - Optional. Set only if using authentication. -
- - https://www.zigbee2mqtt.io/guide/configuration/mqtt.html - -
-
- - - -
- - https://www.zigbee2mqtt.io/guide/configuration/frontend.html - -
- -
- - https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html - -
-
- - -
- - https://www.zigbee2mqtt.io/guide/configuration/logging.html - -
- -
-
- - - -`; - /* v8 ignore stop */ -} - -function generateHtmlError(errors: string): string { - return ` - - - - - - Zigbee2MQTT Onboarding - - - -
-

Zigbee2MQTT configuration is not valid

-

Found the following errors:

- ${errors} -
-

If you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration

-
- -
-
- - -`; -} - function getServerUrl(): URL { return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); } +function getZipEntryTargetPath(entryName: string): string { + const normalizedEntry = entryName.replace(/\\/g, "/"); + + if (!normalizedEntry || normalizedEntry.startsWith("/") || normalizedEntry.includes("\0")) { + throw new Error(`Invalid ZIP entry path '${entryName}'`); + } + + const basePath = path.resolve(data.getPath()); + const targetPath = path.resolve(basePath, normalizedEntry); + const relativePath = path.relative(basePath, targetPath); + + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error(`Unsafe ZIP entry path '${entryName}'`); + } + + return targetPath; +} + +async function extractZipDataToDataPath(zipContent: Buffer): Promise { + const zip = await JSZip.loadAsync(zipContent); + + for (const key in zip.files) { + const entry = zip.files[key]; + const targetPath = getZipEntryTargetPath(entry.name); + + if (entry.dir) { + mkdirSync(targetPath, {recursive: true}); + + continue; + } + + mkdirSync(path.dirname(targetPath), {recursive: true}); + writeFileSync(targetPath, await entry.async("nodebuffer")); + } +} + async function startOnboardingServer(): Promise { const currentSettings = settings.get(); const serverUrl = getServerUrl(); let server: ReturnType | undefined; - let failed = false; + const fileServer = expressStaticGzip( + // TODO: tempfix: windfront<>z2m typing dep + ((await import("zigbee2mqtt-windfront")).default as unknown as {getOnboardingPath: () => string}).getOnboardingPath(), + FILE_SERVER_OPTIONS, + ); const success = await new Promise((resolve) => { server = createServer(async (req, res) => { + const pathname = new URL(req.url /* v8 ignore next */ ?? "/", serverUrl).pathname; + + if (req.method === "GET" && pathname === "/data") { + const payload: OnboardData = { + page: "form", + settings: currentSettings, + settingsSchema: settings.schemaJson, + devices: await findAllDevices(), + }; + + res.setHeader("Content-Type", "application/json"); + res.writeHead(200); + res.end(stringify(payload)); + + return; + } + if (req.method === "POST") { - if (failed) { - res.end(() => { - resolve(false); - }); - } else { + if (pathname === "/submit") { let body = ""; req.on("data", (chunk) => { @@ -400,92 +104,108 @@ async function startOnboardingServer(): Promise { }); req.on("end", () => { - const result = parse(body) as unknown as OnboardSettings; - const frontendEnabled = result.frontend_enabled === "on"; - const updatedSettings: RecursivePartial = { - mqtt: { - base_topic: result.mqtt_base_topic, - server: result.mqtt_server, - user: result.mqtt_user || undefined, // empty string => removed - password: result.mqtt_password || undefined, // empty string => removed - }, - serial: { - port: result.serial_port, - adapter: result.serial_adapter, - baudrate: result.serial_baudrate ? Number.parseInt(result.serial_baudrate, 10) : undefined, - rtscts: result.serial_rtscts === "on", - }, - advanced: { - log_level: result.log_level, - channel: result.network_channel ? Number.parseInt(result.network_channel, 10) : undefined, - network_key: result.network_key - ? result.network_key === "GENERATE" - ? result.network_key - : result.network_key.split(",").map((v) => Number.parseInt(v, 10)) - : undefined, - pan_id: result.network_pan_id - ? result.network_pan_id === "GENERATE" - ? result.network_pan_id - : Number.parseInt(result.network_pan_id, 10) - : undefined, - ext_pan_id: result.network_ext_pan_id - ? result.network_ext_pan_id === "GENERATE" - ? result.network_ext_pan_id - : result.network_ext_pan_id.split(",").map((v) => Number.parseInt(v, 10)) - : undefined, - }, - frontend: { - enabled: frontendEnabled, - port: result.frontend_port ? Number.parseInt(result.frontend_port, 10) : undefined, - }, - homeassistant: { - enabled: result.homeassistant_enabled === "on", - }, - }; - try { - settings.apply(updatedSettings); + const result = (body ? JSON.parse(body) : {}) as RecursivePartial; - // to redirect, make sure frontend "will be" enabled, and host isn't socket + settings.apply(result); + + const appliedSettings = settings.get(); const redirect = !process.env.Z2M_ONBOARD_NO_REDIRECT && - frontendEnabled && - (!currentSettings.frontend?.host || !currentSettings.frontend.host.startsWith("/")); - const protocol = currentSettings.frontend?.ssl_cert && currentSettings.frontend.ssl_key ? "https" : "http"; + appliedSettings.frontend.enabled && + (!appliedSettings.frontend.host || !appliedSettings.frontend.host.startsWith("/")); + const protocol = appliedSettings.frontend.ssl_cert && appliedSettings.frontend.ssl_key ? "https" : "http"; + const frontendUrl = redirect + ? `${protocol}://${appliedSettings.frontend.host ?? "localhost"}:${appliedSettings.frontend.port}${appliedSettings.frontend.base_url}` + : null; + const payload: OnboardSubmitResponse = {success: true, frontendUrl}; - res.setHeader("Content-Type", "text/html"); + res.setHeader("Content-Type", "application/json"); res.writeHead(200); - res.end( - generateHtmlDone( - redirect - ? /* v8 ignore next */ `${protocol}://${currentSettings.frontend?.host ?? "localhost"}:${currentSettings.frontend?.port ?? "8080"}${currentSettings.frontend?.base_url ?? "/"}` - : undefined, - ), - () => { - resolve(true); - }, - ); + res.end(stringify(payload), () => { + resolve(true); + }); } catch (error) { console.error(`Failed to apply configuration: ${(error as Error).message}`); - failed = true; - if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) { - res.end(() => { - resolve(false); - }); - } else { - res.setHeader("Content-Type", "text/html"); - res.writeHead(406); - res.end(generateHtmlError(`

${escapeHtml((error as Error).message)}

`)); - } + const payload: OnboardSubmitResponse = {success: false, error: (error as Error).message}; + + res.setHeader("Content-Type", "application/json"); + res.writeHead(406); + res.end(stringify(payload)); } }); + + req.on("error", (error: Error) => { + console.error(`Failed to parse request body: ${error.message}`); + + const payload: OnboardSubmitResponse = {success: false, error: error.message}; + + res.setHeader("Content-Type", "application/json"); + res.writeHead(406); + res.end(stringify(payload)); + }); + + return; + } + + if (pathname === "/submit-zip") { + let body = ""; + + req.on("data", (chunk) => { + body += chunk; + }); + + req.on("end", async () => { + try { + if (!body) { + throw new Error("Invalid ZIP payload: missing content"); + } + + const zipContent = Buffer.from(body, "base64"); + + await extractZipDataToDataPath(zipContent); + + const payload: OnboardSubmitResponse = {success: true, frontendUrl: null}; + + res.setHeader("Content-Type", "application/json"); + res.writeHead(200); + res.end(stringify(payload), () => { + resolve(true); + }); + } catch (error) { + console.error(`Failed to apply ZIP data: ${(error as Error).message}`); + + const payload: OnboardSubmitResponse = {success: false, error: (error as Error).message}; + + res.setHeader("Content-Type", "application/json"); + res.writeHead(406); + res.end(stringify(payload)); + } + }); + + req.on("error", (error: Error) => { + console.error(`Failed to parse ZIP request body: ${error.message}`); + + const payload: OnboardSubmitResponse = {success: false, error: error.message}; + + res.setHeader("Content-Type", "application/json"); + res.writeHead(406); + res.end(stringify(payload)); + }); + + return; } - } else { - res.setHeader("Content-Type", "text/html"); - res.writeHead(200); - res.end(generateHtmlForm(currentSettings, await findAllDevices())); } + + const next = finalhandler(req, res); + + fileServer(req, res, next); + }); + + server.on("error", (error: Error) => { + console.error("Failed to start onboarding server", error); + resolve(false); }); server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => { @@ -498,21 +218,41 @@ async function startOnboardingServer(): Promise { return success; } -async function startFailureServer(errors: string): Promise { +async function startFailureServer(errors: string[]): Promise { const serverUrl = getServerUrl(); let server: ReturnType | undefined; + const fileServer = expressStaticGzip( + // TODO: tempfix: windfront<>z2m typing dep + ((await import("zigbee2mqtt-windfront")).default as unknown as {getOnboardingPath: () => string}).getOnboardingPath(), + FILE_SERVER_OPTIONS, + ); await new Promise((resolve) => { server = createServer((req, res) => { - if (req.method === "POST") { + const pathname = new URL(req.url /* v8 ignore next */ ?? "/", serverUrl).pathname; + + if (req.method === "GET" && pathname === "/data") { + const payload: OnboardFailureData = {page: "failure", errors}; + + res.setHeader("Content-Type", "application/json"); + res.writeHead(200); + res.end(stringify(payload)); + + return; + } + + if (req.method === "POST" && pathname === "/submit") { + res.writeHead(200); res.end(() => { resolve(); }); - } else { - res.setHeader("Content-Type", "text/html"); - res.writeHead(406); - res.end(generateHtmlError(errors)); + + return; } + + const next = finalhandler(req, res); + + fileServer(req, res, next); }); server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => { @@ -524,23 +264,19 @@ async function startFailureServer(errors: string): Promise { } async function onSettingsErrors(errors: string[]): Promise { - let pErrors = ""; - console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); console.error(" READ THIS CAREFULLY\n"); console.error("Refusing to start because configuration is not valid, found the following errors:"); for (const error of errors) { console.error(`- ${error}`); - - pErrors += `

- ${escapeHtml(error)}

`; } console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration"); console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n"); if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) { - await startFailureServer(pErrors); + await startFailureServer(errors); } } diff --git a/test/onboarding.test.ts b/test/onboarding.test.ts index 3c366cadd..08ba99855 100644 --- a/test/onboarding.test.ts +++ b/test/onboarding.test.ts @@ -2,15 +2,18 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; import * as data from "./mocks/data"; -import {rmSync, writeFileSync} from "node:fs"; +import {readFileSync, rmSync, writeFileSync} from "node:fs"; import {join} from "node:path"; import type {IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, RequestListener, Server, ServerResponse} from "node:http"; +import JSZip from "jszip"; import type {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery"; +import type {OnboardFailureData, OnboardInitData, OnboardSubmitResponse} from "../lib/types/api"; import {onboard} from "../lib/util/onboarding"; import * as settings from "../lib/util/settings"; const mockHttpOnListen = vi.fn(() => Promise.resolve()); const mockHttpListener = vi.fn>(); +let mockHttpErrorListener: ((error: Error) => void) | undefined; const mockHttpListen = vi.fn( // @ts-expect-error mocked for used definition async (_port, _host, listeningListener) => { @@ -28,6 +31,16 @@ const mockHttpClose = vi.fn( }, ); const mockFindAllDevices = vi.fn(async () => []); +const mockStaticFileServer = vi.fn((_req, res, next) => { + if (typeof next === "function") { + next(); + } + + res.end(); +}); +const mockExpressStaticGzip = vi.fn((_path: unknown, _options: unknown) => mockStaticFileServer); +const mockFinalHandlerNext = vi.fn(); +const mockFinalhandler = vi.fn((_req: unknown, _res: unknown) => mockFinalHandlerNext); vi.mock("node:fs", {spy: true}); vi.mock("node:http", () => ({ @@ -39,12 +52,30 @@ vi.mock("node:http", () => ({ return { listen: mockHttpListen, close: mockHttpClose, + on: vi.fn((event: string, cb: (error: Error) => void) => { + if (event === "error") { + mockHttpErrorListener = cb; + } + + return this; + }), }; }), })); +vi.mock("express-static-gzip", () => ({ + default: vi.fn((path, options) => mockExpressStaticGzip(path, options)), +})); +vi.mock("finalhandler", () => ({ + default: vi.fn((req, res) => mockFinalhandler(req, res)), +})); vi.mock("zigbee-herdsman/dist/adapter/adapterDiscovery", () => ({ findAllDevices: vi.fn(() => mockFindAllDevices()), })); +vi.mock("zigbee2mqtt-windfront", () => ({ + default: { + getOnboardingPath: () => data.mockDir, + }, +})); const SETTINGS_MINIMAL_DEFAULTS = { version: settings.CURRENT_VERSION, @@ -127,25 +158,6 @@ const SAMPLE_SETTINGS_SAVE = { onboarding: true, }; -const SAMPLE_SETTINGS_SAVE_PARAMS = { - mqtt_base_topic: "zigbee2mqtt2", - mqtt_server: "mqtt://192.168.1.200:1883", - mqtt_user: "", - mqtt_password: "", - serial_port: "COM3", - serial_adapter: "ember", - serial_baudrate: "230400", - serial_rtscts: "on", - log_level: "debug", - network_channel: "25", - network_key: "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16", - network_pan_id: "12345", - network_ext_pan_id: "8,7,6,5,4,3,2,1", - frontend_enabled: "on", - frontend_port: "8080", - homeassistant_enabled: "on", -}; - describe("Onboarding", () => { beforeAll(() => { vi.useFakeTimers(); @@ -178,19 +190,29 @@ describe("Onboarding", () => { mockHttpListen.mockClear(); mockHttpClose.mockClear(); mockFindAllDevices.mockClear(); + mockHttpErrorListener = undefined; + mockStaticFileServer.mockClear(); + mockExpressStaticGzip.mockClear(); + mockFinalHandlerNext.mockClear(); + mockFinalhandler.mockClear(); + mockStaticFileServer.mockClear(); settings.reRead(); }); afterEach(() => {}); const runOnboarding = async ( - params: Record, + params: Record, expectWriteMinimal: boolean, expectFailure: boolean, - ): Promise<[getHtml: string, postHtml: string]> => { + ): Promise<[getData: OnboardInitData, submitData: OnboardSubmitResponse]> => { // biome-ignore lint/suspicious/noExplicitAny: ignore const reqDataListener = vi.fn<(chunk: any) => void>(); const reqEndListener = vi.fn<() => void>(); + let resolveResponse: () => void = () => {}; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); // biome-ignore lint/suspicious/noExplicitAny: ignore const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( // @ts-expect-error return not used @@ -200,6 +222,8 @@ describe("Onboarding", () => { } else if (cb) { cb(); } + + resolveResponse(); }, ); const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); @@ -211,6 +235,7 @@ describe("Onboarding", () => { mockHttpListener( { method: "GET", + url: "/data", // @ts-expect-error return not used on: () => {}, }, @@ -233,13 +258,14 @@ describe("Onboarding", () => { } expect(mockFindAllDevices).toHaveBeenCalledTimes(1); - expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "text/html"); + expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(1, 200); expect(resEnd).toHaveBeenCalledTimes(1); mockHttpListener( { method: "POST", + url: "/submit", // @ts-expect-error return not used on: (event, listener) => { if (event === "data") { @@ -257,37 +283,16 @@ describe("Onboarding", () => { }, ); - for (const k in params) { - reqDataListener(`${k}=${params[k as keyof typeof params]}&`); - } - + reqDataListener(JSON.stringify(params)); reqEndListener(); - await vi.advanceTimersByTimeAsync(100); // flush + await responsePromise; if (expectFailure) { - if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) { - expect(resEnd).toHaveBeenCalledTimes(2); - } else { - mockHttpListener( - { - method: "POST", - // @ts-expect-error return not used - on: () => {}, - }, - { - end: resEnd, - setHeader: resSetHeader, - writeHead: resWriteHead, - }, - ); - await vi.advanceTimersByTimeAsync(100); // flush - - expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "text/html"); - expect(resWriteHead).toHaveBeenNthCalledWith(2, 406); - expect(resEnd).toHaveBeenCalledTimes(3); - } + expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "application/json"); + expect(resWriteHead).toHaveBeenNthCalledWith(2, 406); + expect(resEnd).toHaveBeenCalledTimes(2); } else { - expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "text/html"); + expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(2, 200); expect(resEnd).toHaveBeenCalledTimes(2); } @@ -295,10 +300,14 @@ describe("Onboarding", () => { const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function)); - return [resEnd.mock.calls[0][0], resEnd.mock.calls[1][0]]; + return [JSON.parse(resEnd.mock.calls[0][0]) as OnboardInitData, JSON.parse(resEnd.mock.calls[1][0]) as OnboardSubmitResponse]; }; - const runFailure = async (): Promise => { + const runFailure = async (): Promise => { + let resolveResponse: () => void = () => {}; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); // biome-ignore lint/suspicious/noExplicitAny: ignore const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( // @ts-expect-error return not used @@ -308,6 +317,8 @@ describe("Onboarding", () => { } else if (cb) { cb(); } + + resolveResponse(); }, ); const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); @@ -319,6 +330,7 @@ describe("Onboarding", () => { mockHttpListener( { method: "GET", + url: "/data", // @ts-expect-error return not used on: () => {}, }, @@ -330,13 +342,14 @@ describe("Onboarding", () => { ); await vi.advanceTimersByTimeAsync(100); // flush - expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "text/html"); - expect(resWriteHead).toHaveBeenNthCalledWith(1, 406); + expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); + expect(resWriteHead).toHaveBeenNthCalledWith(1, 200); expect(resEnd).toHaveBeenCalledTimes(1); mockHttpListener( { method: "POST", + url: "/submit", // @ts-expect-error return not used on: () => {}, }, @@ -346,24 +359,543 @@ describe("Onboarding", () => { writeHead: resWriteHead, }, ); - await vi.advanceTimersByTimeAsync(100); // flush + await responsePromise; expect(resEnd).toHaveBeenCalledTimes(2); const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function)); - return resEnd.mock.calls[0][0]; + return JSON.parse(resEnd.mock.calls[0][0]) as OnboardFailureData; }; + const submitPayload = async ( + payload: Record, + fail: boolean, + reqError: boolean, + submitEmpty = false, + ): Promise => { + // biome-ignore lint/suspicious/noExplicitAny: ignore + const reqDataListener = vi.fn<(chunk: any) => void>(); + const reqEndListener = vi.fn<() => void>(); + const reqErrorListener = vi.fn<(error: Error) => void>(); + let resolveResponse: () => void = () => {}; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + // biome-ignore lint/suspicious/noExplicitAny: ignore + const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( + // @ts-expect-error return not used + (chunk, cb) => { + if (typeof chunk === "function") { + chunk(); + } else if (cb) { + cb(); + } + + resolveResponse(); + }, + ); + const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); + const resWriteHead = + vi.fn< + (statusCode: number, statusMessage?: string, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]) => ServerResponse + >(); + + mockHttpListener( + { + method: "POST", + url: "/submit", + // @ts-expect-error return not used + on: (event, listener) => { + if (event === "data") { + reqDataListener.mockImplementation(listener); + } else if (event === "end") { + // @ts-expect-error typing not narrowed + reqEndListener.mockImplementation(listener); + } else if (event === "error") { + reqErrorListener.mockImplementation(listener); + } + }, + }, + { + end: resEnd, + setHeader: resSetHeader, + writeHead: resWriteHead, + }, + ); + + if (reqError) { + reqErrorListener(new Error("request error submit")); + } else { + reqDataListener(submitEmpty ? "" : JSON.stringify(payload)); + reqEndListener(); + } + + await responsePromise; + + expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); + expect(resWriteHead).toHaveBeenNthCalledWith(1, fail || reqError ? 406 : 200); + + return JSON.parse(resEnd.mock.calls[0][0]) as OnboardSubmitResponse; + }; + + const submitZipPayload = async (payload: string, fail: boolean, reqError: boolean): Promise => { + // biome-ignore lint/suspicious/noExplicitAny: ignore + const reqDataListener = vi.fn<(chunk: any) => void>(); + const reqEndListener = vi.fn<() => void>(); + const reqErrorListener = vi.fn<(error: Error) => void>(); + let resolveResponse: () => void = () => {}; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + // biome-ignore lint/suspicious/noExplicitAny: ignore + const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( + // @ts-expect-error return not used + (chunk, cb) => { + if (typeof chunk === "function") { + chunk(); + } else if (cb) { + cb(); + } + + resolveResponse(); + }, + ); + const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); + const resWriteHead = + vi.fn< + (statusCode: number, statusMessage?: string, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]) => ServerResponse + >(); + + mockHttpListener( + { + method: "POST", + url: "/submit-zip", + // @ts-expect-error return not used + on: (event, listener) => { + if (event === "data") { + reqDataListener.mockImplementation(listener); + } else if (event === "end") { + // @ts-expect-error typing not narrowed + reqEndListener.mockImplementation(listener); + } else if (event === "error") { + reqErrorListener.mockImplementation(listener); + } + }, + }, + { + end: resEnd, + setHeader: resSetHeader, + writeHead: resWriteHead, + }, + ); + + if (reqError) { + reqErrorListener(new Error("request error submit-zip")); + } else { + reqDataListener(payload); + reqEndListener(); + } + + await responsePromise; + + expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); + expect(resWriteHead).toHaveBeenNthCalledWith(1, fail || reqError ? 406 : 200); + + return JSON.parse(resEnd.mock.calls[0][0]) as OnboardSubmitResponse; + }; + + const requestUnhandledRoute = async (url: string): Promise => { + let resolveResponse: () => void = () => {}; + const responsePromise = new Promise((resolve) => { + resolveResponse = resolve; + }); + const resEnd = vi.fn((chunk?: unknown, cb?: () => void) => { + if (typeof chunk === "function") { + chunk(); + } else if (cb) { + cb(); + } + + resolveResponse(); + }); + + mockHttpListener( + { + method: "GET", + url, + // @ts-expect-error return not used + on: () => {}, + }, + { + end: resEnd, + setHeader: vi.fn(), + writeHead: vi.fn(), + }, + ); + + await responsePromise; + }; + + const createZipRestore = (): Awaited> => { + return { + files: { + "configuration.yaml": { + name: "configuration.yaml", + dir: false, + // @ts-expect-error minimal mock + async: async () => await Promise.resolve(Buffer.from(JSON.stringify(SAMPLE_SETTINGS_SAVE))), + }, + // @ts-expect-error minimal mock + "nested/": { + name: "nested/", + dir: true, + }, + "nested/notes.txt": { + name: "nested/notes.txt", + dir: false, + // @ts-expect-error minimal mock + async: async () => await Promise.resolve(Buffer.from("zip-restore")), + }, + }, + }; + }; + + it("extracts uploaded ZIP files into the data path", async () => { + data.removeConfiguration(); + const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore()); + + try { + let p; + const submitData = await new Promise((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + resolve(await submitZipPayload(Buffer.from("zip").toString("base64"), false, false)); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); + expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore"); + expect(loadAsyncSpy).toHaveBeenCalledTimes(1); + expect(submitData).toStrictEqual({success: true, frontendUrl: null}); + } finally { + loadAsyncSpy.mockRestore(); + } + }); + + it("rejects non-zip upload payloads", async () => { + data.removeConfiguration(); + const loadAsyncSpy = vi + .spyOn(JSZip, "loadAsync") + .mockRejectedValueOnce(new Error("Can't find end of central directory : is this a zip file ?")) + .mockResolvedValueOnce(createZipRestore()); + + try { + let p; + const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const failedSubmit = await submitZipPayload(Buffer.from("ignored").toString("base64"), true, false); + const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); + + resolve([failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(loadAsyncSpy).toHaveBeenCalledTimes(2); + expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); + expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore"); + expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("is this a zip file")}); + expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); + } finally { + loadAsyncSpy.mockRestore(); + } + }); + + it("rejects ZIP upload payloads with invalid entry paths", async () => { + data.removeConfiguration(); + const loadAsyncSpy = vi + .spyOn(JSZip, "loadAsync") + .mockResolvedValueOnce({ + files: { + "/dragons.txt": { + name: "/dragons.txt", + dir: false, + // @ts-expect-error minimal mock + async: async () => await Promise.resolve(Buffer.from("dragons")), + }, + }, + }) + .mockResolvedValueOnce(createZipRestore()); + + try { + let p; + const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const failedSubmit = await submitZipPayload(Buffer.from("zip-invalid-path").toString("base64"), true, false); + const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); + + resolve([failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Invalid ZIP entry path")}); + expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); + expect(loadAsyncSpy).toHaveBeenCalledTimes(2); + } finally { + loadAsyncSpy.mockRestore(); + } + }); + + it("rejects ZIP upload payloads with unsafe relative entry paths", async () => { + data.removeConfiguration(); + const loadAsyncSpy = vi + .spyOn(JSZip, "loadAsync") + .mockResolvedValueOnce({ + files: { + "../dragons.txt": { + name: "../dragons.txt", + dir: false, + // @ts-expect-error minimal mock + async: async () => await Promise.resolve(Buffer.from("dragons")), + }, + }, + }) + .mockResolvedValueOnce(createZipRestore()); + + try { + let p; + const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const failedSubmit = await submitZipPayload(Buffer.from("zip-unsafe-path").toString("base64"), true, false); + const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); + + resolve([failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Unsafe ZIP entry path")}); + expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); + expect(loadAsyncSpy).toHaveBeenCalledTimes(2); + } finally { + loadAsyncSpy.mockRestore(); + } + }); + + it("handles empty ZIP upload payloads", async () => { + data.removeConfiguration(); + const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore()); + + try { + let p; + const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const failedSubmit = await submitZipPayload("", true, false); + const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); + + resolve([failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(firstSubmitData).toStrictEqual({success: false, error: "Invalid ZIP payload: missing content"}); + expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); + expect(loadAsyncSpy).toHaveBeenCalledTimes(1); + } finally { + loadAsyncSpy.mockRestore(); + } + }); + + it("handles request stream errors for submit endpoint", async () => { + process.env.Z2M_ONBOARD_FORCE_RUN = "1"; + + let p; + const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const failedSubmit = await submitPayload(SAMPLE_SETTINGS_SAVE, false, true); + const successfulSubmit = await submitPayload(SAMPLE_SETTINGS_SAVE, false, false); + + resolve([failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit"}); + expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); + }); + + it("handles request stream errors for submit-zip endpoint", async () => { + data.removeConfiguration(); + const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore()); + + try { + let p; + const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const failedSubmit = await submitZipPayload("", true, true); + const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); + + resolve([failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit-zip"}); + expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); + expect(loadAsyncSpy).toHaveBeenCalledTimes(1); + } finally { + loadAsyncSpy.mockRestore(); + } + }); + + it("passes unknown onboarding routes to static file server", async () => { + data.removeConfiguration(); + + let p; + await new Promise((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + await requestUnhandledRoute("/unknown"); + await submitPayload(SAMPLE_SETTINGS_SAVE, false, false); + resolve(); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(true); + expect(mockFinalhandler).toHaveBeenCalled(); + expect(mockStaticFileServer).toHaveBeenCalled(); + }); + + it("passes unknown failure-pages routes to static file server", async () => { + settings.set(["serial"], "/dev/ttyUSB0"); + + let p; + await new Promise((resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + await requestUnhandledRoute("/unknown"); + resolve(await runFailure()); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(false); + expect(mockFinalhandler).toHaveBeenCalled(); + expect(mockStaticFileServer).toHaveBeenCalled(); + }); + + it("returns false when onboarding server emits an error", async () => { + data.removeConfiguration(); + + mockHttpOnListen.mockImplementationOnce(() => { + mockHttpErrorListener?.(new Error("listen failed")); + + return Promise.resolve(); + }); + + const p = onboard(); + + await expect(p).resolves.toStrictEqual(false); + }); + + it("handles empty config submit", async () => { + process.env.Z2M_ONBOARD_FORCE_RUN = "1"; + + let p; + const [getData, firstSubmit, secondSubmit] = await new Promise<[OnboardInitData, OnboardSubmitResponse, OnboardSubmitResponse]>( + (resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const [dataPayload, failedSubmit] = await runOnboarding( + Object.assign({}, SAMPLE_SETTINGS_SAVE, { + serial: { + adapter: "emberz", + }, + }), + false, + true, + ); + const successfulSubmit = await submitPayload({}, false, false, true); + + resolve([dataPayload, failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }, + ); + + await expect(p).resolves.toStrictEqual(true); + expect(data.read()).toStrictEqual(Object.assign({}, SAMPLE_SETTINGS_INIT, {onboarding: true})); + expect(getData.devices).toStrictEqual([]); + expect(firstSubmit).toMatchObject({success: false, error: expect.stringContaining("adapter must be equal to one of the allowed values")}); + expect(secondSubmit).toStrictEqual({success: true, frontendUrl: null}); + }); + it("creates config file and sets given settings", async () => { data.removeConfiguration(); let p; - const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { + const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, true, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); } catch (error) { reject(error); } @@ -374,9 +906,10 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); - expect(getHtml).toContain("No device found"); - expect(getHtml).not.toContain("generate_network"); - expect(postHtml).toContain(''); + expect(getData.page).toStrictEqual("form"); + expect(getData.devices).toStrictEqual([]); + expect(getData.settingsSchema).toBeDefined(); + expect(submitData).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); }); it("creates config file and sets given unusual settings", async () => { @@ -390,18 +923,24 @@ describe("Onboarding", () => { ]); let p; - const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { + const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve( await runOnboarding( - Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, { - mqtt_user: "abcd", - mqtt_password: "defg", - frontend_enabled: undefined, - network_key: "GENERATE", - network_pan_id: "GENERATE", - network_ext_pan_id: "GENERATE", + Object.assign({}, SAMPLE_SETTINGS_SAVE, { + mqtt: { + user: "abcd", + password: "defg", + }, + frontend: { + enabled: false, + }, + advanced: { + network_key: "GENERATE", + pan_id: "GENERATE", + ext_pan_id: "GENERATE", + }, }), true, false, @@ -417,30 +956,41 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual( - Object.assign({}, SAMPLE_SETTINGS_SAVE, { + Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, { advanced: { - log_level: SAMPLE_SETTINGS_SAVE.advanced.log_level, - channel: SAMPLE_SETTINGS_SAVE.advanced.channel, + log_level: SETTINGS_MINIMAL_DEFAULTS.advanced.log_level, + channel: SETTINGS_MINIMAL_DEFAULTS.advanced.channel, network_key: "GENERATE", pan_id: "GENERATE", ext_pan_id: "GENERATE", }, + serial: { + port: SAMPLE_SETTINGS_SAVE.serial.port, + adapter: SAMPLE_SETTINGS_SAVE.serial.adapter, + baudrate: SAMPLE_SETTINGS_SAVE.serial.baudrate, + rtscts: SAMPLE_SETTINGS_SAVE.serial.rtscts, + }, frontend: { enabled: false, - port: SAMPLE_SETTINGS_SAVE.frontend.port, + port: SETTINGS_MINIMAL_DEFAULTS.frontend.port, }, mqtt: { - base_topic: SAMPLE_SETTINGS_SAVE.mqtt.base_topic, + base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic, server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, user: "abcd", password: "defg", }, + homeassistant: { + enabled: true, + }, }), ); - expect(getHtml).toContain(`'); + expect(getData.devices).toStrictEqual([]); + expect(getData.settings.serial.port).toStrictEqual(SAMPLE_SETTINGS_INIT.serial.port); + expect(submitData).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); }); it("reruns onboard on failed start", async () => { @@ -473,10 +1023,10 @@ describe("Onboarding", () => { settings.setOnboarding(true); let p; - const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { + const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } @@ -487,9 +1037,9 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); - expect(getHtml).toContain("No device found"); - expect(getHtml).toContain("generate_network"); - expect(postHtml).toContain(''); + expect(getData.devices).toStrictEqual([]); + expect(getData.settings.serial.port).toStrictEqual(SAMPLE_SETTINGS_INIT.serial.port); + expect(submitData).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); }); it("sets given settings - no frontend redirect", async () => { @@ -501,10 +1051,10 @@ describe("Onboarding", () => { }); let p; - const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { + const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } @@ -523,8 +1073,8 @@ describe("Onboarding", () => { }, }), ); - expect(getHtml).toContain("No device found"); - expect(postHtml).toContain("You can close this page"); + expect(getData.devices).toStrictEqual([]); + expect(submitData).toStrictEqual({success: true, frontendUrl: null}); }); it("sets given settings - no frontend redirect via ENV", async () => { @@ -533,10 +1083,10 @@ describe("Onboarding", () => { process.env.Z2M_ONBOARD_NO_REDIRECT = "1"; let p; - const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { + const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } @@ -547,8 +1097,8 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); - expect(getHtml).toContain("No device found"); - expect(postHtml).toContain("You can close this page"); + expect(getData.devices).toStrictEqual([]); + expect(submitData).toStrictEqual({success: true, frontendUrl: null}); }); it("sets given settings - frontend SSL redirect", async () => { @@ -561,10 +1111,10 @@ describe("Onboarding", () => { }); let p; - const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { + const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } @@ -584,45 +1134,58 @@ describe("Onboarding", () => { }, }), ); - expect(getHtml).toContain("No device found"); - expect(postHtml).toContain(''); + expect(getData.devices).toStrictEqual([]); + expect(submitData).toStrictEqual({success: true, frontendUrl: "https://localhost:8080/"}); }); it("handles saving errors", async () => { process.env.Z2M_ONBOARD_FORCE_RUN = "1"; let p; - const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { - mockHttpOnListen.mockImplementationOnce(async () => { - try { - resolve(await runOnboarding(Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, {serial_adapter: "emberz"}), false, true)); - } catch (error) { - reject(error); - } - }); + const [getData, firstSubmit, secondSubmit] = await new Promise<[OnboardInitData, OnboardSubmitResponse, OnboardSubmitResponse]>( + (resolve, reject) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + const [dataPayload, failedSubmit] = await runOnboarding( + Object.assign({}, SAMPLE_SETTINGS_SAVE, { + serial: { + adapter: "emberz", + }, + }), + false, + true, + ); + const successfulSubmit = await submitPayload(SAMPLE_SETTINGS_SAVE, false, false); - p = onboard(); - }); + resolve([dataPayload, failedSubmit, successfulSubmit]); + } catch (error) { + reject(error); + } + }); - await expect(p).resolves.toStrictEqual(false); - expect(data.read()).toStrictEqual(Object.assign({}, SAMPLE_SETTINGS_INIT, {onboarding: true})); - expect(getHtml).toContain("No device found"); - expect(postHtml).toContain("adapter must be equal to one of the allowed values"); + p = onboard(); + }, + ); + + await expect(p).resolves.toStrictEqual(true); + expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); + expect(getData.devices).toStrictEqual([]); + expect(firstSubmit).toMatchObject({success: false, error: expect.stringContaining("adapter must be equal to one of the allowed values")}); + expect(secondSubmit).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); }); it("handles configuring onboarding via ENV", async () => { data.removeConfiguration(); process.env.Z2M_ONBOARD_URL = "http://192.168.1.123:8888"; - process.env.Z2M_ONBOARD_NO_FAILURE_PAGE = "1"; process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = "mqtt://core-mosquitto:1883"; let p; - await new Promise<[string, string]>((resolve, reject) => { + await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, {serial_adapter: "emberz"}), true, true)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); } catch (error) { reject(error); } @@ -631,10 +1194,10 @@ describe("Onboarding", () => { p = onboard(); }); - await expect(p).resolves.toStrictEqual(false); + await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual( - Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, { - mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic}, + Object.assign({}, SAMPLE_SETTINGS_SAVE, { + mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SAMPLE_SETTINGS_SAVE.mqtt.base_topic}, }), ); }); @@ -669,21 +1232,22 @@ describe("Onboarding", () => { let p; - await new Promise<[string, string]>((resolve, reject) => { + await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { - const newSettings = Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS); - // @ts-expect-error mock disabled field - delete newSettings.serial_baudrate; - // @ts-expect-error mock disabled field - delete newSettings.network_channel; - // @ts-expect-error mock disabled field - delete newSettings.network_key; - // @ts-expect-error mock disabled field - delete newSettings.network_pan_id; - // @ts-expect-error mock disabled field - delete newSettings.network_ext_pan_id; - // @ts-expect-error mock disabled field - delete newSettings.frontend_port; + const newSettings = { + ...SAMPLE_SETTINGS_SAVE, + serial: { + port: SAMPLE_SETTINGS_SAVE.serial.port, + adapter: SAMPLE_SETTINGS_SAVE.serial.adapter, + rtscts: SAMPLE_SETTINGS_SAVE.serial.rtscts, + }, + advanced: { + log_level: SAMPLE_SETTINGS_SAVE.advanced.log_level, + }, + frontend: { + enabled: SAMPLE_SETTINGS_SAVE.frontend.enabled, + }, + }; try { resolve(await runOnboarding(newSettings, false, false)); @@ -762,7 +1326,7 @@ describe("Onboarding", () => { }); let p; - const getHtml = await new Promise((resolve, reject) => { + const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -775,7 +1339,9 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(getHtml).toContain("adapter must be equal to one of the allowed values"); + expect( + getData.errors.some((error) => error.includes("serial") && error.includes("adapter") && error.includes("allowed values")), + ).toStrictEqual(true); reReadSpy.mockRestore(); }); @@ -784,7 +1350,7 @@ describe("Onboarding", () => { settings.set(["serial"], "/dev/ttyUSB0"); let p; - const getHtml = await new Promise((resolve, reject) => { + const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -797,7 +1363,7 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(getHtml).toContain("serial must be object"); + expect(getData.errors).toContain("serial must be object"); }); it("handles invalid yaml file", async () => { @@ -814,7 +1380,7 @@ describe("Onboarding", () => { ); let p; - const getHtml = await new Promise((resolve, reject) => { + const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -827,8 +1393,7 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(getHtml).toContain("Your configuration file"); - expect(getHtml).toContain("is invalid"); + expect(getData.errors.some((error) => error.includes("Your configuration file") && error.includes("is invalid"))).toStrictEqual(true); data.removeConfiguration(); }); @@ -841,7 +1406,7 @@ describe("Onboarding", () => { writeFileSync(configFile, "badfile"); let p; - const getHtml = await new Promise((resolve, reject) => { + const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -854,8 +1419,7 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(getHtml).toContain("AssertionError"); - expect(getHtml).toContain("expected to be an object"); + expect(getData.errors.some((error) => error.includes("AssertionError") && error.includes("expected to be an object"))).toStrictEqual(true); data.removeConfiguration(); }); @@ -865,10 +1429,10 @@ describe("Onboarding", () => { settings.testing.clear(); let p; - await new Promise<[string, string]>((resolve, reject) => { + await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, true, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); } catch (error) { reject(error); }