feat: convert wake-on-lan into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 15:49:55 -05:00
1 parent d4ec444558
commit 2ac7779eda
50 files changed
+17844 -199

No files matched your search

@@ -0,0 +1,190 @@
/**
* The Wake-on-LAN host settings migration.
*
* An upgrade must be lossless: every host with a MAC address or a broadcast
* address must come through into the wake-on-lan plugin's host settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "wake-on-lan" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: mac_address");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
wakeOnLanSettingsFromRow,
runWakeOnLanSettingsMigration,
} from "../../utils/crypto-migration/wake-on-lan-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("wakeOnLanSettingsFromRow", () => {
it("carries over a MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: null,
}),
).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
});
it("carries over a broadcast address alongside the MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: "192.168.1.255",
}),
).toEqual({
macAddress: "aa:bb:cc:dd:ee:ff",
broadcastAddress: "192.168.1.255",
});
});
it("produces nothing for a host with neither set", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: null,
wol_broadcast_address: null,
}),
).toEqual({});
});
});
describe("runWakeOnLanSettingsMigration", () => {
it("moves every host's wake-on-lan settings and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
{
id: 2,
mac_address: "11:22:33:44:55:66",
wol_broadcast_address: "192.168.1.255",
},
{ id: 3, mac_address: null, wol_broadcast_address: null },
];
const result = await runWakeOnLanSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
expect(hostValues(2)).toEqual({
macAddress: "11:22:33:44:55:66",
broadcastAddress: "192.168.1.255",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runWakeOnLanSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "wake-on-lan",
scope: "host",
scopeId: "1",
key: "macAddress",
value: JSON.stringify("11:11:11:11:11:11"),
});
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
expect(hostValues(1)).toEqual({ macAddress: "11:11:11:11:11:11" });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
state.pluginInstalled = false;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
-120
View File
@@ -1,120 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("dgram", () => {
const socket = {
once: vi.fn(),
bind: vi.fn(),
setBroadcast: vi.fn(),
send: vi.fn(),
close: vi.fn(),
};
return { default: { createSocket: vi.fn(() => socket) } };
});
import dgram from "dgram";
import {
isValidMac,
buildMagicPacket,
sendWakeOnLan,
} from "../../utils/wake-on-lan.js";
describe("isValidMac", () => {
it("accepts colon-separated MAC addresses", () => {
expect(isValidMac("01:23:45:67:89:AB")).toBe(true);
expect(isValidMac("aa:bb:cc:dd:ee:ff")).toBe(true);
});
it("accepts hyphen-separated MAC addresses", () => {
expect(isValidMac("01-23-45-67-89-AB")).toBe(true);
});
it("rejects malformed MAC addresses", () => {
expect(isValidMac("")).toBe(false);
expect(isValidMac("01:23:45:67:89")).toBe(false);
expect(isValidMac("01:23:45:67:89:AB:CD")).toBe(false);
expect(isValidMac("0123456789AB")).toBe(false);
expect(isValidMac("zz:23:45:67:89:ab")).toBe(false);
expect(isValidMac("01:23:45:67:89:AB ")).toBe(false);
});
});
describe("buildMagicPacket", () => {
it("produces a 102-byte packet", () => {
const packet = buildMagicPacket("01:23:45:67:89:AB");
expect(packet.length).toBe(102);
});
it("starts with six 0xFF bytes", () => {
const packet = buildMagicPacket("01:23:45:67:89:AB");
expect([...packet.subarray(0, 6)]).toEqual([
0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
]);
});
it("repeats the MAC bytes 16 times after the header", () => {
const packet = buildMagicPacket("01:23:45:67:89:AB");
const mac = Buffer.from([0x01, 0x23, 0x45, 0x67, 0x89, 0xab]);
for (let i = 0; i < 16; i++) {
const offset = 6 + i * 6;
expect([...packet.subarray(offset, offset + 6)]).toEqual([...mac]);
}
});
it("treats colon and hyphen separators identically", () => {
const colon = buildMagicPacket("aa:bb:cc:dd:ee:ff");
const hyphen = buildMagicPacket("aa-bb-cc-dd-ee-ff");
expect(colon.equals(hyphen)).toBe(true);
});
});
describe("sendWakeOnLan", () => {
let mockSocket: ReturnType<typeof dgram.createSocket>;
beforeEach(() => {
vi.clearAllMocks();
mockSocket = (dgram.createSocket as ReturnType<typeof vi.fn>)();
(mockSocket.bind as ReturnType<typeof vi.fn>).mockImplementation(
(cb: () => void) => cb(),
);
(mockSocket.send as ReturnType<typeof vi.fn>).mockImplementation(
(
_buf: unknown,
_off: unknown,
_len: unknown,
_port: unknown,
_addr: unknown,
cb: (err: null) => void,
) => cb(null),
);
});
it("rejects on invalid MAC address", async () => {
await expect(sendWakeOnLan("not-a-mac")).rejects.toThrow(
"Invalid MAC address",
);
});
it("sends to 255.255.255.255 by default", async () => {
await sendWakeOnLan("aa:bb:cc:dd:ee:ff");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
102,
9,
"255.255.255.255",
expect.any(Function),
);
});
it("sends to a custom broadcast address when provided", async () => {
await sendWakeOnLan("aa:bb:cc:dd:ee:ff", "192.168.1.255");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
102,
9,
"192.168.1.255",
expect.any(Function),
);
});
});