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 += 'Select a device ';
+
+ for (const device of devices) {
+ // just in case name has commas, remove them to not mess with `split` logic
+ const deviceStr = `${device.name.replaceAll(",", "")}, ${device.path}, ${device.adapter ?? "unknown"}`;
+
+ devicesSelect += `${deviceStr} `;
+ }
+
+ 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 = `
+
+
+ Generate network?
+
+`;
+ }
+
+ /* 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).
+
+
+
+
+
+
+`;
+ /* 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(``);
+ expect(getHtml).toContain(` `);
+ expect(getHtml).toContain(process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER);
+ expect(postHtml).toContain("You can close this page");
});
it("reruns onboard via ENV and sets given settings", async () => {
@@ -999,10 +449,10 @@ describe("Onboarding", () => {
process.env.Z2M_ONBOARD_FORCE_RUN = "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);
}
@@ -1013,9 +463,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("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);
}