// 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 {mockLogger} from "../mocks/logger"; import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt"; import {type EventHandler, flushPromises} from "../mocks/utils"; import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman"; import path from "node:path"; import stringify from "json-stable-stringify-without-jsonify"; import type {Mock} from "vitest"; import ws from "ws"; import {Controller} from "../../lib/controller"; import * as settings from "../../lib/util/settings"; let mockHTTPOnRequest: (request: {url: string}, response: number) => void; const mockHTTPEvents: Record = {}; const mockHTTP = { listen: vi.fn(), on: (event: string, handler: EventHandler): void => { mockHTTPEvents[event] = handler; }, close: vi.fn<(cb: (err?: Error) => void) => void>((cb) => cb()), }; const mockHTTPSEvents: Record = {}; const mockHTTPS = { listen: vi.fn(), on: (event: string, handler: EventHandler): void => { mockHTTPSEvents[event] = handler; }, close: vi.fn<(cb: (err?: Error) => void) => void>((cb) => cb()), }; const mockWSocket = { close: vi.fn<(cb: (err?: Error) => void) => void>(), }; const mockWSClientEvents: Record = {}; const mockWSClient = { on: (event: string, handler: EventHandler): void => { mockWSClientEvents[event] = handler; }, send: vi.fn<(data: string) => void>(), terminate: vi.fn<() => void>(), readyState: "close", }; const mockWSEvents: Record = {}; const mockWSClients: (typeof mockWSClient)[] = []; const mockWS = { clients: mockWSClients, on: (event: string, handler: EventHandler): void => { mockWSEvents[event] = handler; }, handleUpgrade: vi.fn().mockImplementation((_request, _socket, _head, cb) => { cb(mockWSocket); }), // biome-ignore lint/suspicious/noExplicitAny: ignore emit: vi.fn<(eventName: string, ...args: any[]) => void>(), close: vi.fn<(code?: number, data?: string | Buffer) => void>(), }; const frontendPath = "frontend-path"; const deviceIconsPath = path.join(data.mockDir, "device_icons"); let mockNodeStatic: {[s: string]: Mock} = {}; const mockFinalHandler = vi.fn(); vi.mock("node:http", () => ({ createServer: vi.fn().mockImplementation((onRequest) => { mockHTTPOnRequest = onRequest; return mockHTTP; }), Agent: vi.fn(), })); vi.mock("node:https", () => ({ createServer: vi.fn().mockImplementation(() => mockHTTPS), Agent: vi.fn(), })); vi.mock("express-static-gzip", () => ({ default: vi.fn().mockImplementation((path: string) => { mockNodeStatic[path] = vi.fn(); return mockNodeStatic[path]; }), })); vi.mock("zigbee2mqtt-windfront", () => ({ default: { getPath: (): string => frontendPath, }, })); vi.mock("ws", () => ({ default: { OPEN: "open", Server: vi.fn().mockImplementation(() => { return mockWS; }), }, })); vi.mock("finalhandler", () => ({ default: vi.fn().mockImplementation(() => { return mockFinalHandler; }), })); const mocksClear = [ mockHTTP.close, mockHTTP.listen, mockHTTPS.close, mockHTTPS.listen, mockWSocket.close, mockWS.close, mockWS.handleUpgrade, mockWS.emit, mockWSClient.send, mockWSClient.terminate, mockFinalHandler, mockMQTTPublishAsync, mockLogger.error, ]; describe("Extension: Frontend", () => { let controller: Controller; beforeAll(() => { vi.useFakeTimers(); }); beforeEach(() => { mockNodeStatic = {}; mockWS.clients = []; data.writeDefaultConfiguration(); data.writeDefaultState(); settings.reRead(); settings.set(["frontend"], {enabled: true, port: 8081, host: "127.0.0.1"}); settings.set(["homeassistant"], {enabled: true}); devices.bulb.linkquality = 10; for (const mock of mocksClear) mock.mockClear(); mockWSClient.readyState = "close"; }); afterAll(() => { vi.useRealTimers(); }); afterEach(async () => { delete devices.bulb.linkquality; await controller?.stop(); await flushPromises(); }); it("Start/stop with defaults", async () => { controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]); expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); mockWS.clients.push(mockWSClient); await controller.stop(); expect(mockWSClient.terminate).toHaveBeenCalledTimes(1); expect(mockHTTP.close).toHaveBeenCalledTimes(1); expect(mockWS.close).toHaveBeenCalledTimes(1); }); it("Start/stop without host", async () => { settings.set(["frontend"], {enabled: true, port: 8081}); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]); expect(mockHTTP.listen).toHaveBeenCalledWith(8081); mockWS.clients.push(mockWSClient); await controller.stop(); expect(mockWSClient.terminate).toHaveBeenCalledTimes(1); expect(mockHTTP.close).toHaveBeenCalledTimes(1); expect(mockWS.close).toHaveBeenCalledTimes(1); }); it("Start/stop unix socket", async () => { settings.set(["frontend", "host"], "/tmp/zigbee2mqtt.sock"); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(Object.keys(mockNodeStatic)).toStrictEqual([frontendPath, deviceIconsPath]); expect(mockHTTP.listen).toHaveBeenCalledWith("/tmp/zigbee2mqtt.sock"); mockWS.clients.push(mockWSClient); await controller.stop(); expect(mockWSClient.terminate).toHaveBeenCalledTimes(1); expect(mockHTTP.close).toHaveBeenCalledTimes(1); expect(mockWS.close).toHaveBeenCalledTimes(1); }); it("Start/stop HTTPS valid", async () => { settings.set(["frontend", "ssl_cert"], path.join(__dirname, "..", "assets", "certs", "dummy.crt")); settings.set(["frontend", "ssl_key"], path.join(__dirname, "..", "assets", "certs", "dummy.key")); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(mockHTTP.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTPS.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); await controller.stop(); }); it("Start/stop HTTPS invalid : missing config", async () => { settings.set(["frontend", "ssl_cert"], path.join(__dirname, "..", "assets", "certs", "dummy.crt")); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); await controller.stop(); }); it("Start/stop HTTPS invalid : missing file", async () => { settings.set(["frontend", "ssl_cert"], "filesNotExists.crt"); settings.set(["frontend", "ssl_key"], path.join(__dirname, "..", "assets", "certs", "dummy.key")); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); expect(mockHTTPS.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); await controller.stop(); }); it("Websocket interaction", async () => { controller = new Controller(vi.fn(), vi.fn()); await controller.start(); mockWSClient.readyState = "open"; mockWS.clients.push(mockWSClient); await mockWSEvents.connection(mockWSClient); devices.bulb_color.linkquality = 20; const allTopics = mockWSClient.send.mock.calls.map(([m]) => JSON.parse(m).topic); expect(allTopics).toContain("bridge/devices"); expect(allTopics).toContain("bridge/info"); expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: "bridge/state", payload: {state: "online"}})); expect(mockWSClient.send).toHaveBeenCalledWith(stringify({topic: "remote", payload: {brightness: 255}})); // Message mockMQTTPublishAsync.mockClear(); mockWSClient.send.mockClear(); mockWSClientEvents.message(stringify({topic: "bulb_color/set", payload: {state: "ON"}}), false); await flushPromises(); expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(1); expect(mockMQTTPublishAsync).toHaveBeenCalledWith( "zigbee2mqtt/bulb_color", stringify({ state: "ON", effect: null, effect_color: null, effect_speed: null, power_on_behavior: null, linkquality: 20, update: {state: null, installed_version: -1, latest_version: -1}, }), {retain: false, qos: 0}, ); mockWSClientEvents.message(undefined, false); mockWSClientEvents.message("", false); mockWSClientEvents.message(null, false); await flushPromises(); // Error mockWSClientEvents.error(new Error("This is an error")); expect(mockLogger.error).toHaveBeenCalledWith("WebSocket error: This is an error"); // Received message on socket expect(mockWSClient.send).toHaveBeenCalledTimes(1); expect(mockWSClient.send).toHaveBeenCalledWith( stringify({ topic: "bulb_color", payload: { state: "ON", power_on_behavior: null, effect: null, effect_color: null, effect_speed: null, linkquality: 20, update: {state: null, installed_version: -1, latest_version: -1}, }, }), ); // Should publish bridge messages await mockZHEvents.deviceJoined({device: devices.bulb}); await flushPromises(); expect(mockWSClient.send).toHaveBeenCalledWith( stringify({payload: {data: {friendly_name: "bulb", ieee_address: "0x000b57fffec6a5b2"}, type: "device_joined"}, topic: "bridge/event"}), ); // Should send JSON state event when `output: attribute` mockWSClient.send.mockClear(); settings.set(["advanced", "output"], "attribute"); await mockMQTTEvents.message("zigbee2mqtt/bulb_color/set", stringify({brightness: 90})); await flushPromises(); expect(mockWSClient.send).toHaveBeenCalledTimes(1); expect(mockWSClient.send).toHaveBeenCalledWith( stringify({ topic: "bulb_color", payload: { state: "ON", brightness: 90, power_on_behavior: null, effect: null, effect_color: null, effect_speed: null, linkquality: 20, update: {state: null, installed_version: -1, latest_version: -1}, }, }), ); // Shouldnt set when not ready mockWSClient.send.mockClear(); mockWSClient.readyState = "close"; mockWSClientEvents.message(stringify({topic: "bulb_color/set", payload: {state: "ON"}}), false); expect(mockWSClient.send).toHaveBeenCalledTimes(0); // Send last seen on connect mockWSClient.send.mockClear(); mockWSClient.readyState = "open"; settings.set(["advanced"], {last_seen: "ISO_8601"}); mockWS.clients.push(mockWSClient); await mockWSEvents.connection(mockWSClient); expect(mockWSClient.send).toHaveBeenCalledWith( stringify({topic: "remote", payload: {brightness: 255, last_seen: "1970-01-01T00:00:01.000Z"}}), ); }); it("onRequest/onUpgrade", async () => { controller = new Controller(vi.fn(), vi.fn()); await controller.start(); const mockSocket = {destroy: vi.fn()}; mockHTTPEvents.upgrade({url: "http://localhost:8080/api"}, mockSocket, 3); expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1); expect(mockSocket.destroy).toHaveBeenCalledTimes(0); expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: "http://localhost:8080/api"}, mockSocket, 3, expect.any(Function)); mockWS.handleUpgrade.mock.calls[0][3](99); expect(mockWS.emit).toHaveBeenCalledWith("connection", 99, {url: "http://localhost:8080/api"}); mockHTTPOnRequest({url: "/file.txt"}, 2); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(0); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( {originalUrl: "/file.txt", path: "/file.txt", url: "/file.txt"}, 2, expect.any(Function), ); }); it("Should serve device icons", async () => { controller = new Controller(vi.fn(), vi.fn()); await controller.start(); mockHTTPOnRequest({url: "/device_icons/my_device.png"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(0); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith( {originalUrl: "/device_icons/my_device.png", path: "/my_device.png", url: "/my_device.png"}, 2, expect.any(Function), ); }); it("Static server", async () => { controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(mockHTTP.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); }); it("Authentication", async () => { const authToken = "sample-secure-token"; settings.set(["frontend", "auth_token"], authToken); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); const mockSocket = {destroy: vi.fn()}; mockHTTPEvents.upgrade({url: "/api"}, mockSocket, mockWSocket); expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1); expect(mockSocket.destroy).toHaveBeenCalledTimes(0); expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url: "/api"}, mockSocket, mockWSocket, expect.any(Function)); expect(mockWSocket.close).toHaveBeenCalledWith(4401, "Unauthorized"); mockWSocket.close.mockClear(); mockWS.emit.mockClear(); const url = `/api?token=${authToken}`; mockWS.handleUpgrade.mockClear(); mockHTTPEvents.upgrade({url: url}, mockSocket, 3); expect(mockWS.handleUpgrade).toHaveBeenCalledTimes(1); expect(mockSocket.destroy).toHaveBeenCalledTimes(0); expect(mockWS.handleUpgrade).toHaveBeenCalledWith({url}, mockSocket, 3, expect.any(Function)); expect(mockWSocket.close).toHaveBeenCalledTimes(0); mockWS.handleUpgrade.mock.calls[0][3](mockWSocket); expect(mockWS.emit).toHaveBeenCalledWith("connection", mockWSocket, {url}); }); it.each(["/z2m/", "/z2m"])("Works with non-default base url %s", async (baseUrl) => { settings.set(["frontend", "base_url"], baseUrl); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m/api"}); mockHTTPOnRequest({url: "/z2m"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith({originalUrl: "/z2m", path: "/", url: "/"}, 2, expect.any(Function)); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockHTTPOnRequest({url: "/z2m/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( {originalUrl: "/z2m/file.txt", path: "/file.txt", url: "/file.txt"}, 2, expect.any(Function), ); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); mockHTTPOnRequest({url: "/z/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); expect(mockFinalHandler).toHaveBeenCalled(); mockHTTPOnRequest({url: "/z2m/device_icons/my-device.png"}, 2); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledTimes(1); expect(mockNodeStatic[deviceIconsPath]).toHaveBeenCalledWith( {originalUrl: "/z2m/device_icons/my-device.png", path: "/my-device.png", url: "/my-device.png"}, 2, expect.any(Function), ); }); it("Works with non-default complex base url", async () => { const baseUrl = "/z2m-more++/c0mplex.url/"; settings.set(["frontend", "base_url"], baseUrl); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: "/z2m-more++/c0mplex.url/api"}); mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( {originalUrl: "/z2m-more++/c0mplex.url", path: "/", url: "/"}, 2, expect.any(Function), ); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockHTTPOnRequest({url: "/z2m-more++/c0mplex.url/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledTimes(1); expect(mockNodeStatic[frontendPath]).toHaveBeenCalledWith( {originalUrl: "/z2m-more++/c0mplex.url/file.txt", path: "/file.txt", url: "/file.txt"}, 2, expect.any(Function), ); expect(mockFinalHandler).not.toHaveBeenCalledWith(); mockNodeStatic[frontendPath].mockReset(); mockHTTPOnRequest({url: "/z/file.txt"}, 2); expect(mockNodeStatic[frontendPath]).not.toHaveBeenCalled(); expect(mockFinalHandler).toHaveBeenCalled(); }); it("prevents mismatching setting/extension state", async () => { settings.set(["frontend", "enabled"], false); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); await expect(async () => { await controller.enableDisableExtension(true, "Frontend"); }).rejects.toThrow("Tried to enable Frontend extension disabled in settings"); settings.set(["frontend", "enabled"], true); await expect(async () => { await controller.enableDisableExtension(false, "Frontend"); }).rejects.toThrow("Tried to disable Frontend extension enabled in settings"); await controller.enableDisableExtension(true, "Frontend"); settings.set(["frontend", "enabled"], false); await controller.enableDisableExtension(false, "Frontend"); await vi.waitFor(() => controller.getExtension("Frontend") === undefined); }); it("disables serving", async () => { settings.set(["frontend", "disable_ui_serving"], true); controller = new Controller(vi.fn(), vi.fn()); await controller.start(); expect(mockHTTP.listen).toHaveBeenCalledTimes(0); mockWS.clients.push(mockWSClient); await controller.stop(); expect(mockWSClient.terminate).toHaveBeenCalledTimes(1); expect(mockHTTP.close).toHaveBeenCalledTimes(0); expect(mockWS.close).toHaveBeenCalledTimes(1); }); });