// biome-ignore assist/source/organizeImports: import mocks first 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 {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) => { if (typeof listeningListener === "function") { listeningListener(); } await mockHttpOnListen(); }, ); const mockHttpClose = vi.fn( // @ts-expect-error minimal mock (cb) => { cb?.(); }, ); 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", () => ({ createServer: vi.fn((listener) => { if (listener) { mockHttpListener.mockImplementation(listener); } 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, mqtt: { base_topic: settings.defaults.mqtt!.base_topic, server: "mqtt://localhost:1883", }, serial: {}, advanced: { log_level: settings.defaults.advanced!.log_level, channel: settings.defaults.advanced!.channel, network_key: "GENERATE", pan_id: "GENERATE", ext_pan_id: "GENERATE", enable_external_js: false, }, frontend: { enabled: settings.defaults.frontend!.enabled, port: settings.defaults.frontend!.port, }, homeassistant: { enabled: settings.defaults.homeassistant!.enabled, }, onboarding: true, }; const SAMPLE_SETTINGS_INIT = { version: settings.CURRENT_VERSION, mqtt: { base_topic: "zigbee2mqtt", server: "mqtt://localhost:1883", }, serial: { port: "/dev/ttyUSB0", adapter: "zstack", baudrate: 115200, rtscts: false, }, advanced: { log_level: "info", channel: 15, network_key: [13, 53, 58, 7, 93, 131, 113, 215, 40, 32, 4, 26, 8, 110, 142, 213], pan_id: 54321, ext_pan_id: [0xee, 0xdd, 0xcc, 0xdd, 0xaa, 0xdd, 0x11, 0xdd], }, frontend: { enabled: false, port: 8080, }, homeassistant: { enabled: false, }, }; const SAMPLE_SETTINGS_SAVE = { version: settings.CURRENT_VERSION, mqtt: { base_topic: "zigbee2mqtt2", server: "mqtt://192.168.1.200:1883", }, serial: { port: "COM3", adapter: "ember", baudrate: 230400, rtscts: true, }, advanced: { log_level: "debug", channel: 25, network_key: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], pan_id: 12345, ext_pan_id: [8, 7, 6, 5, 4, 3, 2, 1], enable_external_js: false, }, frontend: { enabled: true, port: 8080, }, homeassistant: { enabled: true, }, onboarding: true, }; describe("Onboarding", () => { beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.useRealTimers(); }); beforeEach(() => { delete process.env.Z2M_ONBOARD_NO_SERVER; delete process.env.Z2M_ONBOARD_FORCE_RUN; delete process.env.Z2M_ONBOARD_URL; delete process.env.Z2M_ONBOARD_NO_FAILURE_PAGE; delete process.env.Z2M_ONBOARD_NO_REDIRECT; delete process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER; delete process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE; delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL; delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY; delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID; delete process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID; delete process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT; delete process.env.ZIGBEE2MQTT_CONFIG_FRONTEND; delete process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED; data.writeDefaultConfiguration(SAMPLE_SETTINGS_INIT); data.removeState(); data.removeDatabase(); mockHttpListener.mockClear(); 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, expectWriteMinimal: boolean, expectFailure: boolean, ): Promise<[getData: OnboardInitData, submitData: OnboardSubmitResponse]> => { // biome-ignore lint/suspicious/noExplicitAny: ignore const reqDataListener = vi.fn<(chunk: any) => void>(); const reqEndListener = vi.fn<() => void>(); let resolveResponse: () => void = () => {}; const responsePromise = new Promise((resolve) => { resolveResponse = resolve; }); // biome-ignore lint/suspicious/noExplicitAny: ignore const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( // @ts-expect-error return not used (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: "GET", url: "/data", // @ts-expect-error return not used on: () => {}, }, { end: resEnd, setHeader: resSetHeader, writeHead: resWriteHead, }, ); await vi.advanceTimersByTimeAsync(100); // flush if (expectWriteMinimal) { const minimal = process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER ? Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, { mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic}, }) : SETTINGS_MINIMAL_DEFAULTS; expect(data.read()).toStrictEqual(minimal); } expect(mockFindAllDevices).toHaveBeenCalledTimes(1); expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(1, 200); expect(resEnd).toHaveBeenCalledTimes(1); mockHttpListener( { method: "POST", url: "/submit", // @ts-expect-error return not used on: (event, listener) => { if (event === "data") { reqDataListener.mockImplementation(listener); } else if (event === "end") { // @ts-expect-error typing not narrowed reqEndListener.mockImplementation(listener); } }, }, { end: resEnd, setHeader: resSetHeader, writeHead: resWriteHead, }, ); reqDataListener(JSON.stringify(params)); reqEndListener(); await responsePromise; if (expectFailure) { expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(2, 406); expect(resEnd).toHaveBeenCalledTimes(2); } else { expect(resSetHeader).toHaveBeenNthCalledWith(2, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(2, 200); 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 OnboardInitData, JSON.parse(resEnd.mock.calls[1][0]) as OnboardSubmitResponse]; }; const runFailure = async (): Promise => { let resolveResponse: () => void = () => {}; const responsePromise = new Promise((resolve) => { resolveResponse = resolve; }); // biome-ignore lint/suspicious/noExplicitAny: ignore const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( // @ts-expect-error return not used (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: "GET", url: "/data", // @ts-expect-error return not used on: () => {}, }, { end: resEnd, setHeader: resSetHeader, writeHead: resWriteHead, }, ); await vi.advanceTimersByTimeAsync(100); // flush expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(1, 200); expect(resEnd).toHaveBeenCalledTimes(1); mockHttpListener( { method: "POST", url: "/submit", // @ts-expect-error return not used on: () => {}, }, { end: resEnd, setHeader: resSetHeader, writeHead: resWriteHead, }, ); await responsePromise; expect(resEnd).toHaveBeenCalledTimes(2); const serverUrl = new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); expect(mockHttpListen).toHaveBeenCalledWith(Number.parseInt(serverUrl.port, 10), serverUrl.hostname, expect.any(Function)); return JSON.parse(resEnd.mock.calls[0][0]) as OnboardFailureData; }; const submitPayload = async ( payload: Record, fail: boolean, reqError: boolean, submitEmpty = false, ): Promise => { // biome-ignore lint/suspicious/noExplicitAny: ignore const reqDataListener = vi.fn<(chunk: any) => void>(); const reqEndListener = vi.fn<() => void>(); const reqErrorListener = vi.fn<(error: Error) => void>(); let resolveResponse: () => void = () => {}; const responsePromise = new Promise((resolve) => { resolveResponse = resolve; }); // biome-ignore lint/suspicious/noExplicitAny: ignore const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( // @ts-expect-error return not used (chunk, cb) => { if (typeof chunk === "function") { chunk(); } else if (cb) { cb(); } resolveResponse(); }, ); const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); const resWriteHead = vi.fn< (statusCode: number, statusMessage?: string, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]) => ServerResponse >(); mockHttpListener( { method: "POST", url: "/submit", // @ts-expect-error return not used on: (event, listener) => { if (event === "data") { reqDataListener.mockImplementation(listener); } else if (event === "end") { // @ts-expect-error typing not narrowed reqEndListener.mockImplementation(listener); } else if (event === "error") { reqErrorListener.mockImplementation(listener); } }, }, { end: resEnd, setHeader: resSetHeader, writeHead: resWriteHead, }, ); if (reqError) { reqErrorListener(new Error("request error submit")); } else { reqDataListener(submitEmpty ? "" : JSON.stringify(payload)); reqEndListener(); } await responsePromise; expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(1, fail || reqError ? 406 : 200); return JSON.parse(resEnd.mock.calls[0][0]) as OnboardSubmitResponse; }; const submitZipPayload = async (payload: string, fail: boolean, reqError: boolean): Promise => { // biome-ignore lint/suspicious/noExplicitAny: ignore const reqDataListener = vi.fn<(chunk: any) => void>(); const reqEndListener = vi.fn<() => void>(); const reqErrorListener = vi.fn<(error: Error) => void>(); let resolveResponse: () => void = () => {}; const responsePromise = new Promise((resolve) => { resolveResponse = resolve; }); // biome-ignore lint/suspicious/noExplicitAny: ignore const resEnd = vi.fn<(chunk: any | (() => void), cb?: () => void) => ServerResponse>( // @ts-expect-error return not used (chunk, cb) => { if (typeof chunk === "function") { chunk(); } else if (cb) { cb(); } resolveResponse(); }, ); const resSetHeader = vi.fn<(name: string, value: number | string | readonly string[]) => ServerResponse>(); const resWriteHead = vi.fn< (statusCode: number, statusMessage?: string, headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]) => ServerResponse >(); mockHttpListener( { method: "POST", url: "/submit-zip", // @ts-expect-error return not used on: (event, listener) => { if (event === "data") { reqDataListener.mockImplementation(listener); } else if (event === "end") { // @ts-expect-error typing not narrowed reqEndListener.mockImplementation(listener); } else if (event === "error") { reqErrorListener.mockImplementation(listener); } }, }, { end: resEnd, setHeader: resSetHeader, writeHead: resWriteHead, }, ); if (reqError) { reqErrorListener(new Error("request error submit-zip")); } else { reqDataListener(payload); reqEndListener(); } await responsePromise; expect(resSetHeader).toHaveBeenNthCalledWith(1, "Content-Type", "application/json"); expect(resWriteHead).toHaveBeenNthCalledWith(1, fail || reqError ? 406 : 200); return JSON.parse(resEnd.mock.calls[0][0]) as OnboardSubmitResponse; }; const requestUnhandledRoute = async (url: string): Promise => { let resolveResponse: () => void = () => {}; const responsePromise = new Promise((resolve) => { resolveResponse = resolve; }); const resEnd = vi.fn((chunk?: unknown, cb?: () => void) => { if (typeof chunk === "function") { chunk(); } else if (cb) { cb(); } resolveResponse(); }); mockHttpListener( { method: "GET", url, // @ts-expect-error return not used on: () => {}, }, { end: resEnd, setHeader: vi.fn(), writeHead: vi.fn(), }, ); await responsePromise; }; const createZipRestore = (): Awaited> => { return { files: { "configuration.yaml": { name: "configuration.yaml", dir: false, // @ts-expect-error minimal mock async: async () => await Promise.resolve(Buffer.from(JSON.stringify(SAMPLE_SETTINGS_SAVE))), }, // @ts-expect-error minimal mock "nested/": { name: "nested/", dir: true, }, "nested/notes.txt": { name: "nested/notes.txt", dir: false, // @ts-expect-error minimal mock async: async () => await Promise.resolve(Buffer.from("zip-restore")), }, }, }; }; it("extracts uploaded ZIP files into the data path", async () => { data.removeConfiguration(); const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore()); try { let p; const submitData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await submitZipPayload(Buffer.from("zip").toString("base64"), false, false)); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore"); expect(loadAsyncSpy).toHaveBeenCalledTimes(1); expect(submitData).toStrictEqual({success: true, frontendUrl: null}); } finally { loadAsyncSpy.mockRestore(); } }); it("rejects non-zip upload payloads", async () => { data.removeConfiguration(); const loadAsyncSpy = vi .spyOn(JSZip, "loadAsync") .mockRejectedValueOnce(new Error("Can't find end of central directory : is this a zip file ?")) .mockResolvedValueOnce(createZipRestore()); try { let p; const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { const failedSubmit = await submitZipPayload(Buffer.from("ignored").toString("base64"), true, false); const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); resolve([failedSubmit, successfulSubmit]); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(loadAsyncSpy).toHaveBeenCalledTimes(2); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); expect(readFileSync(join(data.mockDir, "nested", "notes.txt"), "utf8")).toStrictEqual("zip-restore"); expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("is this a zip file")}); expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); } finally { loadAsyncSpy.mockRestore(); } }); it("rejects ZIP upload payloads with invalid entry paths", async () => { data.removeConfiguration(); const loadAsyncSpy = vi .spyOn(JSZip, "loadAsync") .mockResolvedValueOnce({ files: { "/dragons.txt": { name: "/dragons.txt", dir: false, // @ts-expect-error minimal mock async: async () => await Promise.resolve(Buffer.from("dragons")), }, }, }) .mockResolvedValueOnce(createZipRestore()); try { let p; const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { const failedSubmit = await submitZipPayload(Buffer.from("zip-invalid-path").toString("base64"), true, false); const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); resolve([failedSubmit, successfulSubmit]); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Invalid ZIP entry path")}); expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); expect(loadAsyncSpy).toHaveBeenCalledTimes(2); } finally { loadAsyncSpy.mockRestore(); } }); it("rejects ZIP upload payloads with unsafe relative entry paths", async () => { data.removeConfiguration(); const loadAsyncSpy = vi .spyOn(JSZip, "loadAsync") .mockResolvedValueOnce({ files: { "../dragons.txt": { name: "../dragons.txt", dir: false, // @ts-expect-error minimal mock async: async () => await Promise.resolve(Buffer.from("dragons")), }, }, }) .mockResolvedValueOnce(createZipRestore()); try { let p; const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { const failedSubmit = await submitZipPayload(Buffer.from("zip-unsafe-path").toString("base64"), true, false); const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); resolve([failedSubmit, successfulSubmit]); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(firstSubmitData).toStrictEqual({success: false, error: expect.stringContaining("Unsafe ZIP entry path")}); expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); expect(loadAsyncSpy).toHaveBeenCalledTimes(2); } finally { loadAsyncSpy.mockRestore(); } }); it("handles empty ZIP upload payloads", async () => { data.removeConfiguration(); const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore()); try { let p; const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { const failedSubmit = await submitZipPayload("", true, false); const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); resolve([failedSubmit, successfulSubmit]); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(firstSubmitData).toStrictEqual({success: false, error: "Invalid ZIP payload: missing content"}); expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); expect(loadAsyncSpy).toHaveBeenCalledTimes(1); } finally { loadAsyncSpy.mockRestore(); } }); it("handles request stream errors for submit endpoint", async () => { process.env.Z2M_ONBOARD_FORCE_RUN = "1"; let p; const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { const failedSubmit = await submitPayload(SAMPLE_SETTINGS_SAVE, false, true); const successfulSubmit = await submitPayload(SAMPLE_SETTINGS_SAVE, false, false); resolve([failedSubmit, successfulSubmit]); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit"}); expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: "http://localhost:8080/"}); }); it("handles request stream errors for submit-zip endpoint", async () => { data.removeConfiguration(); const loadAsyncSpy = vi.spyOn(JSZip, "loadAsync").mockResolvedValue(createZipRestore()); try { let p; const [firstSubmitData, secondSubmitData] = await new Promise<[OnboardSubmitResponse, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { const failedSubmit = await submitZipPayload("", true, true); const successfulSubmit = await submitZipPayload(Buffer.from("zip").toString("base64"), false, false); resolve([failedSubmit, successfulSubmit]); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(firstSubmitData).toStrictEqual({success: false, error: "request error submit-zip"}); expect(secondSubmitData).toStrictEqual({success: true, frontendUrl: null}); expect(loadAsyncSpy).toHaveBeenCalledTimes(1); } finally { loadAsyncSpy.mockRestore(); } }); it("passes unknown onboarding routes to static file server", async () => { data.removeConfiguration(); let p; await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { await requestUnhandledRoute("/unknown"); await submitPayload(SAMPLE_SETTINGS_SAVE, false, false); resolve(); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(mockFinalhandler).toHaveBeenCalled(); expect(mockStaticFileServer).toHaveBeenCalled(); }); it("passes unknown failure-pages routes to static file server", async () => { settings.set(["serial"], "/dev/ttyUSB0"); let p; await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { await requestUnhandledRoute("/unknown"); resolve(await runFailure()); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(false); expect(mockFinalhandler).toHaveBeenCalled(); expect(mockStaticFileServer).toHaveBeenCalled(); }); it("returns false when onboarding server emits an error", async () => { data.removeConfiguration(); mockHttpOnListen.mockImplementationOnce(() => { mockHttpErrorListener?.(new Error("listen failed")); return Promise.resolve(); }); const p = onboard(); await expect(p).resolves.toStrictEqual(false); }); it("handles empty config submit", async () => { process.env.Z2M_ONBOARD_FORCE_RUN = "1"; let p; const [getData, firstSubmit, secondSubmit] = await new Promise<[OnboardInitData, OnboardSubmitResponse, OnboardSubmitResponse]>( (resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { const [dataPayload, failedSubmit] = await runOnboarding( Object.assign({}, SAMPLE_SETTINGS_SAVE, { serial: { adapter: "emberz", }, }), false, true, ); const successfulSubmit = await submitPayload({}, false, false, true); resolve([dataPayload, failedSubmit, successfulSubmit]); } catch (error) { reject(error); } }); p = onboard(); }, ); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(Object.assign({}, SAMPLE_SETTINGS_INIT, {onboarding: true})); expect(getData.devices).toStrictEqual([]); expect(firstSubmit).toMatchObject({success: false, error: expect.stringContaining("adapter must be equal to one of the allowed values")}); expect(secondSubmit).toStrictEqual({success: true, frontendUrl: null}); }); it("creates config file and sets given settings", async () => { data.removeConfiguration(); let p; const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); } catch (error) { reject(error); } }); p = onboard(); }); 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/"}); }); it("creates config file and sets given unusual settings", async () => { data.removeConfiguration(); process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = "mqtt://core-mosquitto:1883"; mockFindAllDevices.mockResolvedValueOnce([ {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", adapter: undefined}, ]); let p; const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((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", }, }), true, false, ), ); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual( Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, { advanced: { log_level: SETTINGS_MINIMAL_DEFAULTS.advanced.log_level, channel: SETTINGS_MINIMAL_DEFAULTS.advanced.channel, network_key: "GENERATE", pan_id: "GENERATE", ext_pan_id: "GENERATE", enable_external_js: false, }, 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, }, mqtt: { base_topic: SETTINGS_MINIMAL_DEFAULTS.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}); }); it("reruns onboard via ENV and sets given settings", async () => { // data.removeConfiguration(); process.env.Z2M_ONBOARD_FORCE_RUN = "1"; let p; const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } }); p = onboard(); }); 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/"}); }); it("reruns onboard on failed start", async () => { // data.removeConfiguration(); settings.setOnboarding(true); let p; const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } }); p = onboard(); }); 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/"}); }); it("sets given settings - no frontend redirect", async () => { data.removeConfiguration(); vi.spyOn(settings, "writeMinimalDefaults").mockImplementationOnce(() => { settings.writeMinimalDefaults(); settings.set(["frontend", "host"], "/run/zigbee2mqtt/zigbee2mqtt.sock"); }); let p; const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual( Object.assign({}, SAMPLE_SETTINGS_SAVE, { frontend: { enabled: SAMPLE_SETTINGS_SAVE.frontend.enabled, port: SAMPLE_SETTINGS_SAVE.frontend.port, host: "/run/zigbee2mqtt/zigbee2mqtt.sock", }, }), ); expect(getData.devices).toStrictEqual([]); expect(submitData).toStrictEqual({success: true, frontendUrl: null}); }); it("sets given settings - no frontend redirect via ENV", async () => { data.removeConfiguration(); process.env.Z2M_ONBOARD_NO_REDIRECT = "1"; let p; const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); expect(getData.devices).toStrictEqual([]); expect(submitData).toStrictEqual({success: true, frontendUrl: null}); }); it("sets given settings - frontend SSL redirect", async () => { data.removeConfiguration(); vi.spyOn(settings, "writeMinimalDefaults").mockImplementationOnce(() => { settings.writeMinimalDefaults(); settings.set(["frontend", "ssl_cert"], "dummy"); settings.set(["frontend", "ssl_key"], "dummy2"); }); let p; const [getData, submitData] = await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, false, false)); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual( Object.assign({}, SAMPLE_SETTINGS_SAVE, { frontend: { enabled: SAMPLE_SETTINGS_SAVE.frontend.enabled, port: SAMPLE_SETTINGS_SAVE.frontend.port, ssl_cert: "dummy", ssl_key: "dummy2", }, }), ); expect(getData.devices).toStrictEqual([]); expect(submitData).toStrictEqual({success: true, frontendUrl: "https://localhost:8080/"}); }); it("handles saving errors", async () => { process.env.Z2M_ONBOARD_FORCE_RUN = "1"; let p; const [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) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); 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}, }), ); }); it("handles disabling onboarding server via ENV", async () => { data.removeConfiguration(); process.env.Z2M_ONBOARD_NO_SERVER = "1"; process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER = "mqtt://core-mosquitto:1883"; const p = onboard(); await expect(p).resolves.toStrictEqual(true); const expected = Object.assign({}, SETTINGS_MINIMAL_DEFAULTS, { mqtt: {server: process.env.ZIGBEE2MQTT_CONFIG_MQTT_SERVER, base_topic: SETTINGS_MINIMAL_DEFAULTS.mqtt.base_topic}, }); // @ts-expect-error mock delete expected.onboarding; expect(data.read()).toStrictEqual(expected); }); it("handles configuring onboarding with config ENV overrides", async () => { process.env.Z2M_ONBOARD_FORCE_RUN = "1"; process.env.ZIGBEE2MQTT_CONFIG_SERIAL_BAUDRATE = "230400"; process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_CHANNEL = "20"; process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_NETWORK_KEY = "[11,22,33,44,55,66,77,88,99,10,11,12,13,14,15,16]"; process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_PAN_ID = "1"; process.env.ZIGBEE2MQTT_CONFIG_ADVANCED_EXT_PAN_ID = "[11,22,33,44,55,66,15,16]"; process.env.ZIGBEE2MQTT_CONFIG_FRONTEND_PORT = "8282"; let p; await new Promise<[OnboardInitData, OnboardSubmitResponse]>((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, }, }; try { resolve(await runOnboarding(newSettings, false, false)); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual( Object.assign({}, SAMPLE_SETTINGS_SAVE, { serial: { port: SAMPLE_SETTINGS_SAVE.serial.port, adapter: SAMPLE_SETTINGS_SAVE.serial.adapter, baudrate: 230400, rtscts: SAMPLE_SETTINGS_SAVE.serial.rtscts, }, advanced: { log_level: SAMPLE_SETTINGS_SAVE.advanced.log_level, channel: 20, network_key: [11, 22, 33, 44, 55, 66, 77, 88, 99, 10, 11, 12, 13, 14, 15, 16], pan_id: 1, ext_pan_id: [11, 22, 33, 44, 55, 66, 15, 16], }, frontend: { enabled: SAMPLE_SETTINGS_SAVE.frontend.enabled, port: 8282, }, }), ); }); it("runs migrations", async () => { settings.set(["version"], settings.CURRENT_VERSION - 1); const p = onboard(); await expect(p).resolves.toStrictEqual(true); expect(settings.get().version).toStrictEqual(settings.CURRENT_VERSION); }); it("runs 1.x.x conflict migrations", async () => { data.writeDefaultConfiguration({ mqtt: { server: "mqtt://core-mosquitto:1883", }, homeassistant: true, advanced: { network_key: "GENERATE", pan_id: "GENERATE", ext_pan_id: "GENERATE", }, }); settings.reRead(); process.env.ZIGBEE2MQTT_CONFIG_FRONTEND = '{"enabled":true,"port": 8099}'; process.env.ZIGBEE2MQTT_CONFIG_HOMEASSISTANT_ENABLED = "true"; const p = onboard(); await expect(p).resolves.toStrictEqual(true); expect(settings.get().version).toStrictEqual(settings.CURRENT_VERSION); expect(settings.get().homeassistant).toMatchObject({enabled: true}); expect(settings.get().frontend).toMatchObject({enabled: true, port: 8099}); }); it("handles validation failure", async () => { const reReadSpy = vi.spyOn(settings, "reRead"); // set after onboarding server is done to reach bottom code path reReadSpy.mockImplementationOnce(() => { settings.set(["serial", "adapter"], "emberz"); settings.reRead(); }); let p; const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(false); expect( getData.errors.some((error) => error.includes("serial") && error.includes("adapter") && error.includes("allowed values")), ).toStrictEqual(true); reReadSpy.mockRestore(); }); it("handles non-required validation failure before applying envs", async () => { settings.set(["serial"], "/dev/ttyUSB0"); let p; const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(false); expect(getData.errors).toContain("serial must be object"); }); it("handles invalid yaml file", async () => { settings.testing.clear(); const configFile = join(data.mockDir, "configuration.yaml"); writeFileSync( configFile, ` good: 9 \t wrong `, ); let p; const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(false); expect(getData.errors.some((error) => error.includes("Your configuration file") && error.includes("is invalid"))).toStrictEqual(true); data.removeConfiguration(); }); it("handles error while loading yaml file", async () => { settings.testing.clear(); const configFile = join(data.mockDir, "configuration.yaml"); writeFileSync(configFile, "badfile"); let p; const getData = await new Promise((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runFailure()); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(false); expect(getData.errors.some((error) => error.includes("AssertionError") && error.includes("expected to be an object"))).toStrictEqual(true); data.removeConfiguration(); }); it("handles creating data path", async () => { rmSync(data.mockDir, {force: true, recursive: true}); settings.testing.clear(); let p; await new Promise<[OnboardInitData, OnboardSubmitResponse]>((resolve, reject) => { mockHttpOnListen.mockImplementationOnce(async () => { try { resolve(await runOnboarding(SAMPLE_SETTINGS_SAVE, true, false)); } catch (error) { reject(error); } }); p = onboard(); }); await expect(p).resolves.toStrictEqual(true); expect(data.read()).toStrictEqual(SAMPLE_SETTINGS_SAVE); }); });