Skip to content

Commit 435a6cc

Browse files
authored
fix: Improve startup settings validation (#27988)
1 parent a51216e commit 435a6cc

File tree

5 files changed

+160
-60
lines changed

5 files changed

+160
-60
lines changed

lib/util/onboarding.ts

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import {existsSync, mkdirSync} from "node:fs";
22
import {createServer} from "node:http";
33
import {parse} from "node:querystring";
4-
54
import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
6-
75
import data from "./data";
86
import * as settings from "./settings";
7+
import {YAMLFileException} from "./yaml";
98

109
type OnboardSettings = {
1110
mqtt_base_topic?: string;
@@ -518,22 +517,66 @@ async function startFailureServer(errors: string): Promise<void> {
518517
await new Promise((resolve) => server?.close(resolve));
519518
}
520519

520+
async function onSettingsErrors(errors: string[]): Promise<void> {
521+
let pErrors = "";
522+
523+
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
524+
console.error(" READ THIS CAREFULLY\n");
525+
console.error("Refusing to start because configuration is not valid, found the following errors:");
526+
527+
for (const error of errors) {
528+
console.error(`- ${error}`);
529+
530+
pErrors += `<p>- ${escapeHtml(error)}</p>`;
531+
}
532+
533+
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
534+
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
535+
536+
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
537+
await startFailureServer(pErrors);
538+
}
539+
}
540+
521541
export async function onboard(): Promise<boolean> {
522542
if (!existsSync(data.getPath())) {
523543
mkdirSync(data.getPath(), {recursive: true});
524544
}
525545

526546
const confExists = existsSync(data.joinPath("configuration.yaml"));
527547

528-
if (!confExists) {
529-
settings.writeMinimalDefaults();
530-
} else {
548+
if (confExists) {
549+
// initial caching, ensure file is valid yaml first
550+
try {
551+
settings.getPersistedSettings();
552+
} catch (error) {
553+
await onSettingsErrors(
554+
error instanceof YAMLFileException
555+
? [`Your configuration file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`]
556+
: [`${error}`],
557+
);
558+
559+
return false;
560+
}
561+
531562
// migrate first
532563
const {migrateIfNecessary} = await import("./settingsMigration.js");
533564

534565
migrateIfNecessary();
566+
567+
// make sure existing settings are valid before applying envs
568+
const errors = settings.validateNonRequired();
569+
570+
if (errors.length > 0) {
571+
await onSettingsErrors(errors);
572+
573+
return false;
574+
}
575+
535576
// trigger initial writing of `ZIGBEE2MQTT_CONFIG_*` ENVs
536577
settings.write();
578+
} else {
579+
settings.writeMinimalDefaults();
537580
}
538581

539582
// use `configuration.yaml` file to detect "brand new install"
@@ -553,24 +596,7 @@ export async function onboard(): Promise<boolean> {
553596
const errors = settings.validate();
554597

555598
if (errors.length > 0) {
556-
let pErrors = "";
557-
558-
console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
559-
console.error(" READ THIS CAREFULLY\n");
560-
console.error("Refusing to start because configuration is not valid, found the following errors:");
561-
562-
for (const error of errors) {
563-
console.error(`- ${error}`);
564-
565-
pErrors += `<p>- ${escapeHtml(error)}</p>`;
566-
}
567-
568-
console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration");
569-
console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n");
570-
571-
if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) {
572-
await startFailureServer(pErrors);
573-
}
599+
await onSettingsErrors(errors);
574600

575601
return false;
576602
}

lib/util/settings.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import objectAssignDeep from "object-assign-deep";
77
import data from "./data";
88
import schemaJson from "./settings.schema.json";
99
import utils from "./utils";
10-
import yaml, {YAMLFileException} from "./yaml";
10+
import yaml from "./yaml";
1111

1212
export {schemaJson};
1313
// When updating also update:
@@ -260,15 +260,7 @@ export function write(): void {
260260
}
261261

262262
export function validate(): string[] {
263-
try {
264-
getPersistedSettings();
265-
} catch (error) {
266-
if (error instanceof YAMLFileException) {
267-
return [`Your YAML file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`];
268-
}
269-
270-
return [`${error}`];
271-
}
263+
getPersistedSettings();
272264

273265
if (!ajvSetting(_settings)) {
274266
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
@@ -321,6 +313,19 @@ export function validate(): string[] {
321313
return errors;
322314
}
323315

316+
export function validateNonRequired(): string[] {
317+
getPersistedSettings();
318+
319+
if (!ajvSetting(_settings)) {
320+
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
321+
const errors = ajvSetting.errors!.filter((e) => e.keyword !== "required");
322+
323+
return errors.map((v) => `${v.instancePath.substring(1)} ${v.message}`);
324+
}
325+
326+
return [];
327+
}
328+
324329
function read(): Partial<Settings> {
325330
const s = yaml.read(CONFIG_FILE_PATH) as Partial<Settings>;
326331

@@ -478,12 +483,12 @@ export function apply(settings: Record<string, unknown>, throwOnError = true): b
478483
const newSettings = objectAssignDeep.noMutate(_settings, settings);
479484

480485
utils.removeNullPropertiesFromObject(newSettings, NULLABLE_SETTINGS);
481-
ajvSetting(newSettings);
482486

483-
if (throwOnError) {
484-
const errors = ajvSetting.errors?.filter((e) => e.keyword !== "required");
487+
if (!ajvSetting(newSettings) && throwOnError) {
488+
// biome-ignore lint/style/noNonNullAssertion: When `ajvSetting()` return false it always has `errors`
489+
const errors = ajvSetting.errors!.filter((e) => e.keyword !== "required");
485490

486-
if (errors?.length) {
491+
if (errors.length) {
487492
const error = errors[0];
488493
throw new Error(`${error.instancePath.substring(1)} ${error.message}`);
489494
}

lib/util/yaml.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export class YAMLFileException extends YAMLException {
2121
function read(file: string): KeyValue {
2222
try {
2323
const result = yaml.load(fs.readFileSync(file, "utf8"));
24-
assert(result instanceof Object);
24+
assert(result instanceof Object, `The content of ${file} is expected to be an object`);
2525
return result as KeyValue;
2626
} catch (error) {
2727
if (error instanceof YAMLException) {

test/onboarding.test.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// biome-ignore assist/source/organizeImports: import mocks first
22
import * as data from "./mocks/data";
33

4-
import {rmSync} from "node:fs";
5-
4+
import {rmSync, writeFileSync} from "node:fs";
5+
import {join} from "node:path";
66
import type {IncomingMessage, OutgoingHttpHeader, OutgoingHttpHeaders, RequestListener, Server, ServerResponse} from "node:http";
77
import type {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery";
88
import {onboard} from "../lib/util/onboarding";
@@ -752,7 +752,13 @@ describe("Onboarding", () => {
752752
});
753753

754754
it("handles validation failure", async () => {
755-
settings.set(["serial", "adapter"], "emberz");
755+
const reReadSpy = vi.spyOn(settings, "reRead");
756+
757+
// set after onboarding server is done to reach bottom code path
758+
reReadSpy.mockImplementationOnce(() => {
759+
settings.set(["serial", "adapter"], "emberz");
760+
settings.reRead();
761+
});
756762

757763
let p;
758764
const getHtml = await new Promise<string>((resolve, reject) => {
@@ -769,6 +775,88 @@ describe("Onboarding", () => {
769775

770776
await expect(p).resolves.toStrictEqual(false);
771777
expect(getHtml).toContain("adapter must be equal to one of the allowed values");
778+
779+
reReadSpy.mockRestore();
780+
});
781+
782+
it("handles non-required validation failure before applying envs", async () => {
783+
settings.set(["serial"], "/dev/ttyUSB0");
784+
785+
let p;
786+
const getHtml = await new Promise<string>((resolve, reject) => {
787+
mockHttpOnListen.mockImplementationOnce(async () => {
788+
try {
789+
resolve(await runFailure());
790+
} catch (error) {
791+
reject(error);
792+
}
793+
});
794+
795+
p = onboard();
796+
});
797+
798+
await expect(p).resolves.toStrictEqual(false);
799+
expect(getHtml).toContain("serial must be object");
800+
});
801+
802+
it("handles invalid yaml file", async () => {
803+
settings.testing.clear();
804+
805+
const configFile = join(data.mockDir, "configuration.yaml");
806+
807+
writeFileSync(
808+
configFile,
809+
`
810+
good: 9
811+
\t wrong
812+
`,
813+
);
814+
815+
let p;
816+
const getHtml = await new Promise<string>((resolve, reject) => {
817+
mockHttpOnListen.mockImplementationOnce(async () => {
818+
try {
819+
resolve(await runFailure());
820+
} catch (error) {
821+
reject(error);
822+
}
823+
});
824+
825+
p = onboard();
826+
});
827+
828+
await expect(p).resolves.toStrictEqual(false);
829+
expect(getHtml).toContain("Your configuration file");
830+
expect(getHtml).toContain("is invalid");
831+
832+
data.removeConfiguration();
833+
});
834+
835+
it("handles error while loading yaml file", async () => {
836+
settings.testing.clear();
837+
838+
const configFile = join(data.mockDir, "configuration.yaml");
839+
840+
writeFileSync(configFile, "badfile");
841+
842+
let p;
843+
const getHtml = await new Promise<string>((resolve, reject) => {
844+
mockHttpOnListen.mockImplementationOnce(async () => {
845+
try {
846+
resolve(await runFailure());
847+
} catch (error) {
848+
reject(error);
849+
}
850+
});
851+
852+
p = onboard();
853+
});
854+
855+
await expect(p).resolves.toStrictEqual(false);
856+
expect(getHtml).toContain("AssertionError");
857+
expect(getHtml).toContain("expected to be an object");
858+
859+
data.removeConfiguration();
772860
});
773861

774862
it("handles creating data path", async () => {

test/settings.test.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -847,25 +847,6 @@ describe("Settings", () => {
847847
expect(settings.get().blocklist).toStrictEqual(["0x123", "0x1234"]);
848848
});
849849

850-
it("Should throw error when yaml file is invalid", () => {
851-
fs.writeFileSync(
852-
configurationFile,
853-
`
854-
good: 9
855-
\t wrong
856-
`,
857-
);
858-
859-
settings.testing.clear();
860-
const error = `Your YAML file: '${configurationFile}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`;
861-
expect(settings.validate()).toEqual(expect.arrayContaining([error]));
862-
});
863-
864-
it("Should throw error when yaml file does not exist", () => {
865-
settings.testing.clear();
866-
expect(settings.validate()[0]).toContain("ENOENT: no such file or directory, open ");
867-
});
868-
869850
it("Configuration shouldnt be valid when invalid QOS value is used", () => {
870851
write(configurationFile, {
871852
...minimalConfig,

0 commit comments

Comments
 (0)