From 7f0a9aa2a30ab79c182d8ea9e875339e9031fdbd Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Mon, 2 Mar 2026 12:10:18 +0100 Subject: [PATCH] fix: Fix onboarding (#31228) --- lib/util/onboarding.ts | 644 ++++++++++++++++++++--------- test/onboarding.test.ts | 880 ++++++++-------------------------------- 2 files changed, 616 insertions(+), 908 deletions(-) diff --git a/lib/util/onboarding.ts b/lib/util/onboarding.ts index 82d7b075..4e2abf5c 100644 --- a/lib/util/onboarding.ts +++ b/lib/util/onboarding.ts @@ -1,98 +1,398 @@ -import {existsSync, mkdirSync, writeFileSync} from "node:fs"; -import type {ServerResponse} from "node:http"; +import {existsSync, mkdirSync} from "node:fs"; import {createServer} from "node:http"; -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 {parse} from "node:querystring"; 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"; -/** 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 */ - }, +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"]; }; +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; - const fileServer = expressStaticGzip((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS); + let failed = false; 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 (pathname === "/submit") { + if (failed) { + res.end(() => { + resolve(false); + }); + } else { let body = ""; req.on("data", (chunk) => { @@ -100,108 +400,92 @@ 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 { - const result = (body ? JSON.parse(body) : {}) as RecursivePartial; + settings.apply(updatedSettings); - settings.apply(result); - - const appliedSettings = settings.get(); + // to redirect, make sure frontend "will be" enabled, and host isn't socket const redirect = !process.env.Z2M_ONBOARD_NO_REDIRECT && - 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}; + frontendEnabled && + (!currentSettings.frontend?.host || !currentSettings.frontend.host.startsWith("/")); + const protocol = currentSettings.frontend?.ssl_cert && currentSettings.frontend.ssl_key ? "https" : "http"; - res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Type", "text/html"); res.writeHead(200); - res.end(stringify(payload), () => { - resolve(true); - }); + res.end( + generateHtmlDone( + redirect + ? /* v8 ignore next */ `${protocol}://${currentSettings.frontend?.host ?? "localhost"}:${currentSettings.frontend?.port ?? "8080"}${currentSettings.frontend?.base_url ?? "/"}` + : undefined, + ), + () => { + resolve(true); + }, + ); } catch (error) { console.error(`Failed to apply configuration: ${(error as Error).message}`); + failed = true; - 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"); + 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 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, () => { @@ -214,37 +498,21 @@ 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((await import("zigbee2mqtt-windfront")).default.getOnboardingPath(), FILE_SERVER_OPTIONS); await new Promise((resolve) => { server = createServer((req, res) => { - 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); + if (req.method === "POST") { res.end(() => { resolve(); }); - - return; + } else { + res.setHeader("Content-Type", "text/html"); + res.writeHead(406); + res.end(generateHtmlError(errors)); } - - const next = finalhandler(req, res); - - fileServer(req, res, next); }); server.listen(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, () => { @@ -256,19 +524,23 @@ 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(errors); + await startFailureServer(pErrors); } } diff --git a/test/onboarding.test.ts b/test/onboarding.test.ts index 08ba9985..3c366cad 100644 --- a/test/onboarding.test.ts +++ b/test/onboarding.test.ts @@ -2,18 +2,15 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; import * as data from "./mocks/data"; -import {readFileSync, rmSync, writeFileSync} from "node:fs"; +import {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) => { @@ -31,16 +28,6 @@ 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", () => ({ @@ -52,30 +39,12 @@ 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, @@ -158,6 +127,25 @@ 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(); @@ -190,29 +178,19 @@ 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<[getData: OnboardInitData, submitData: OnboardSubmitResponse]> => { + ): Promise<[getHtml: string, postHtml: string]> => { // 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 @@ -222,8 +200,6 @@ describe("Onboarding", () => { } else if (cb) { cb(); } - - resolveResponse(); }, ); const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); @@ -235,7 +211,6 @@ describe("Onboarding", () => { mockHttpListener( { method: "GET", - url: "/data", // @ts-expect-error return not used on: () => {}, }, @@ -258,14 +233,13 @@ describe("Onboarding", () => { } expect(mockFindAllDevices).toHaveBeenCalledTimes(1); - expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); + expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "text/html"); 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") { @@ -283,16 +257,37 @@ describe("Onboarding", () => { }, ); - reqDataListener(JSON.stringify(params)); + for (const k in params) { + reqDataListener(`${k}=${params[k as keyof typeof params]}&`); + } + reqEndListener(); - await responsePromise; + await vi.advanceTimersByTimeAsync(100); // flush if (expectFailure) { - expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "application/json"); - expect(resWriteHead).toHaveBeenNthCalledWith(2, 406); - expect(resEnd).toHaveBeenCalledTimes(2); + 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); + } } else { - expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "application/json"); + expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "text/html"); expect(resWriteHead).toHaveBeenNthCalledWith(2, 200); expect(resEnd).toHaveBeenCalledTimes(2); } @@ -300,14 +295,10 @@ 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 [JSON.parse(resEnd.mock.calls[0][0]) as OnboardInitData, JSON.parse(resEnd.mock.calls[1][0]) as OnboardSubmitResponse]; + return [resEnd.mock.calls[0][0], resEnd.mock.calls[1][0]]; }; - const runFailure = async (): Promise => { - let resolveResponse: () => void = () => {}; - const responsePromise = new Promise((resolve) => { - resolveResponse = resolve; - }); + const runFailure = async (): Promise => { // biome-ignore lint/suspicious/noExplicitAny: ignore const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( // @ts-expect-error return not used @@ -317,8 +308,6 @@ describe("Onboarding", () => { } else if (cb) { cb(); } - - resolveResponse(); }, ); const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); @@ -330,7 +319,6 @@ describe("Onboarding", () => { mockHttpListener( { method: "GET", - url: "/data", // @ts-expect-error return not used on: () => {}, }, @@ -342,14 +330,13 @@ describe("Onboarding", () => { ); await vi.advanceTimersByTimeAsync(100); // flush - expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); - expect(resWriteHead).toHaveBeenNthCalledWith(1, 200); + expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "text/html"); + expect(resWriteHead).toHaveBeenNthCalledWith(1, 406); expect(resEnd).toHaveBeenCalledTimes(1); mockHttpListener( { method: "POST", - url: "/submit", // @ts-expect-error return not used on: () => {}, }, @@ -359,543 +346,24 @@ describe("Onboarding", () => { writeHead: resWriteHead, }, ); - await responsePromise; + await vi.advanceTimersByTimeAsync(100); // flush 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 JSON.parse(resEnd.mock.calls[0][0]) as OnboardFailureData; + return resEnd.mock.calls[0][0]; }; - 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 [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, true, false)); } catch (error) { reject(error); } @@ -906,10 +374,9 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); - expect(getData.page).toStrictEqual("form"); - expect(getData.devices).toStrictEqual([]); - expect(getData.settingsSchema).toBeDefined(); - expect(submitData).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); + expect(getHtml).toContain("No device found"); + expect(getHtml).not.toContain("generate_network"); + expect(postHtml).toContain(''); }); it("creates config file and sets given unusual settings", async () => { @@ -923,24 +390,18 @@ describe("Onboarding", () => { ]); let p; - const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve( await runOnboarding( - Object.assign({}, SAMPLE_SETTINGS_SAVE, { - mqtt: { - user: "abcd", - password: "defg", - }, - frontend: { - enabled: false, - }, - advanced: { - network_key: "GENERATE", - pan_id: "GENERATE", - ext_pan_id: "GENERATE", - }, + 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", }), true, false, @@ -956,41 +417,30 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual( - Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, { + Object.assign({}, SAMPLE_SETTINGS_SAVE, { advanced: { - log_level: SETTINGS_MINIMAL_DEFAULTS.advanced.log_level, - channel: SETTINGS_MINIMAL_DEFAULTS.advanced.channel, + log_level: SAMPLE_SETTINGS_SAVE.advanced.log_level, + channel: SAMPLE_SETTINGS_SAVE.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: SETTINGS_MINIMAL_DEFAULTS.frontend.port, + port: SAMPLE_SETTINGS_SAVE.frontend.port, }, mqtt: { - base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic, + base_topic: SAMPLE_SETTINGS_SAVE.mqtt.base_topic, server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, user: "abcd", password: "defg", }, - homeassistant: { - enabled: true, - }, }), ); - expect(getData.devices).toStrictEqual([ - {name: "My Device", path: "/dev/serial/by-id/my-device-001", adapter: "ember"}, - {name: "My Device 2", path: "/dev/serial/by-id/my-device-002"}, - ]); - expect(getData.settings.mqtt.server).toStrictEqual(process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER); - expect(submitData).toStrictEqual({success: true, frontendUrl: null}); + expect(getHtml).toContain(`'); }); it("reruns onboard on failed start", async () => { @@ -1023,10 +473,10 @@ describe("Onboarding", () => { settings.setOnboarding(true); let p; - const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); } catch (error) { reject(error); } @@ -1037,9 +487,9 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); - expect(getData.devices).toStrictEqual([]); - expect(getData.settings.serial.port).toStrictEqual(SAMPLE_SETTINGS_INIT.serial.port); - expect(submitData).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); + expect(getHtml).toContain("No device found"); + expect(getHtml).toContain("generate_network"); + expect(postHtml).toContain(''); }); it("sets given settings - no frontend redirect", async () => { @@ -1051,10 +501,10 @@ describe("Onboarding", () => { }); let p; - const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); } catch (error) { reject(error); } @@ -1073,8 +523,8 @@ describe("Onboarding", () => { }, }), ); - expect(getData.devices).toStrictEqual([]); - expect(submitData).toStrictEqual({success: true, frontendUrl: null}); + expect(getHtml).toContain("No device found"); + expect(postHtml).toContain("You can close this page"); }); it("sets given settings - no frontend redirect via ENV", async () => { @@ -1083,10 +533,10 @@ describe("Onboarding", () => { process.env.Z2M_ONBOARD_NO_REDIRECT = "1"; let p; - const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); } catch (error) { reject(error); } @@ -1097,8 +547,8 @@ describe("Onboarding", () => { await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); - expect(getData.devices).toStrictEqual([]); - expect(submitData).toStrictEqual({success: true, frontendUrl: null}); + expect(getHtml).toContain("No device found"); + expect(postHtml).toContain("You can close this page"); }); it("sets given settings - frontend SSL redirect", async () => { @@ -1111,10 +561,10 @@ describe("Onboarding", () => { }); let p; - const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, false, false)); } catch (error) { reject(error); } @@ -1134,58 +584,18 @@ describe("Onboarding", () => { }, }), ); - expect(getData.devices).toStrictEqual([]); - expect(submitData).toStrictEqual({success: true, frontendUrl: "https://localhost:8080/"}); + expect(getHtml).toContain("No device found"); + expect(postHtml).toContain(''); }); it("handles saving errors", 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(SAMPLE_SETTINGS_SAVE, false, false); - - resolve([dataPayload, failedSubmit, successfulSubmit]); - } catch (error) { - reject(error); - } - }); - - 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.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = "mqtt://core-mosquitto:1883"; - - let p; - - await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + const [getHtml, postHtml] = await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); + resolve(await runOnboarding(Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, {serial_adapter: "emberz"}), false, true)); } catch (error) { reject(error); } @@ -1194,10 +604,37 @@ describe("Onboarding", () => { p = onboard(); }); - await expect(p).resolves.toStrictEqual(true); + 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"); + }); + + 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) => { + mockHttpOnListen.mockImplementationOnce(async () => { + try { + resolve(await runOnboarding(Object.assign({}, SAMPLE_SETTINGS_SAVE_PARAMS, {serial_adapter: "emberz"}), true, true)); + } catch (error) { + reject(error); + } + }); + + p = onboard(); + }); + + await expect(p).resolves.toStrictEqual(false); expect(data.read()).toStrictEqual( - Object.assign({}, SAMPLE_SETTINGS_SAVE, { - mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SAMPLE_SETTINGS_SAVE.mqtt.base_topic}, + Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, { + mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic}, }), ); }); @@ -1232,22 +669,21 @@ describe("Onboarding", () => { let p; - await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { - 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, - }, - }; + 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; try { resolve(await runOnboarding(newSettings, false, false)); @@ -1326,7 +762,7 @@ describe("Onboarding", () => { }); let p; - const getData = await new Promise((resolve, reject) => { + const getHtml = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -1339,9 +775,7 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect( - getData.errors.some((error) => error.includes("serial") && error.includes("adapter") && error.includes("allowed values")), - ).toStrictEqual(true); + expect(getHtml).toContain("adapter must be equal to one of the allowed values"); reReadSpy.mockRestore(); }); @@ -1350,7 +784,7 @@ describe("Onboarding", () => { settings.set(["serial"], "/dev/ttyUSB0"); let p; - const getData = await new Promise((resolve, reject) => { + const getHtml = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -1363,7 +797,7 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(getData.errors).toContain("serial must be object"); + expect(getHtml).toContain("serial must be object"); }); it("handles invalid yaml file", async () => { @@ -1380,7 +814,7 @@ describe("Onboarding", () => { ); let p; - const getData = await new Promise((resolve, reject) => { + const getHtml = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -1393,7 +827,8 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(getData.errors.some((error) => error.includes("Your configuration file") && error.includes("is invalid"))).toStrictEqual(true); + expect(getHtml).toContain("Your configuration file"); + expect(getHtml).toContain("is invalid"); data.removeConfiguration(); }); @@ -1406,7 +841,7 @@ describe("Onboarding", () => { writeFileSync(configFile, "badfile"); let p; - const getData = await new Promise((resolve, reject) => { + const getHtml = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); @@ -1419,7 +854,8 @@ describe("Onboarding", () => { }); await expect(p).resolves.toStrictEqual(false); - expect(getData.errors.some((error) => error.includes("AssertionError") && error.includes("expected to be an object"))).toStrictEqual(true); + expect(getHtml).toContain("AssertionError"); + expect(getHtml).toContain("expected to be an object"); data.removeConfiguration(); }); @@ -1429,10 +865,10 @@ describe("Onboarding", () => { settings.testing.clear(); let p; - await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { + await new Promise<[string, string]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { - resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); + resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE_PARAMS, true, false)); } catch (error) { reject(error); }