178 lines
9.3 KiB
TypeScript
178 lines
9.3 KiB
TypeScript
// Guards the plugin-settings rules: the env name a declaration maps to, per-type coercion, the
|
|
// required/default/secret resolution, and what the admin catalog is allowed to carry.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import type { Plugin } from "./plugin.ts";
|
|
import {
|
|
ENV_PREFIX,
|
|
envName,
|
|
isValidSettingKey,
|
|
resolveSettings,
|
|
settingsDeclError,
|
|
settingsEnvNames,
|
|
strayNames,
|
|
type SettingDecl,
|
|
} from "./settings.ts";
|
|
|
|
function pluginWith(id: string, settings: SettingDecl[]): Plugin {
|
|
return { apiVersion: "0.2.0", id, settings };
|
|
}
|
|
|
|
test("a key becomes one SCREAMING_SNAKE segment under the plugin's own", () => {
|
|
assert.equal(envName("scheduling", "timezone"), "PLUGIN_SETTING_SCHEDULING_TIMEZONE");
|
|
assert.equal(envName("scheduling", "maxShiftHours"), "PLUGIN_SETTING_SCHEDULING_MAX_SHIFT_HOURS");
|
|
assert.equal(envName("my-plugin", "apiBase"), "PLUGIN_SETTING_MY_PLUGIN_API_BASE");
|
|
assert.equal(ENV_PREFIX, "PLUGIN_SETTING_"); // never bare PLUGIN_ — the host owns PLUGIN_DB_*
|
|
});
|
|
|
|
test("the host's own PLUGIN_DB_* variables are unreachable from a declaration", () => {
|
|
// A plugin id "db" with key "url" is exactly the collision the longer prefix rules out.
|
|
assert.notEqual(envName("db", "url"), "PLUGIN_DB_URL");
|
|
assert.equal(envName("db", "url"), "PLUGIN_SETTING_DB_URL");
|
|
});
|
|
|
|
test("a key is camelCase, so the env name is derivable and no two keys collide", () => {
|
|
assert.ok(isValidSettingKey("timezone"));
|
|
assert.ok(isValidSettingKey("maxShiftHours"));
|
|
assert.ok(!isValidSettingKey("max_shift_hours")); // would collide with maxShiftHours
|
|
assert.ok(!isValidSettingKey("MaxShiftHours"));
|
|
assert.ok(!isValidSettingKey("2fa"));
|
|
assert.ok(!isValidSettingKey(""));
|
|
});
|
|
|
|
test("a declaration is refused when it cannot mean what it says", () => {
|
|
const why = (settings: unknown): string => settingsDeclError(settings) ?? "";
|
|
|
|
assert.equal(settingsDeclError([{ key: "a", type: "string" }]), null);
|
|
assert.match(why("nope"), /must be an array/);
|
|
assert.match(why([{ key: "max_hours", type: "number" }]), /max_hours.*camelCase/);
|
|
assert.match(why([{ key: "a", type: "date" }]), /"date".*string, number, boolean, enum, url/);
|
|
assert.match(why([{ key: "a", type: "string" }, { key: "a", type: "number" }]), /declared twice/);
|
|
// required means "boot fails without it", so a default would make the flag a lie.
|
|
assert.match(why([{ default: "x", key: "a", required: true, type: "string" }]), /required.*default.*mutually exclusive/);
|
|
assert.match(why([{ default: 8, key: "a", type: "string" }]), /default.*string/);
|
|
assert.match(why([{ key: "a", type: "enum" }]), /enum.*values/);
|
|
assert.match(why([{ key: "a", type: "enum", values: [] }]), /enum.*values/);
|
|
assert.match(why([{ default: "c", key: "a", type: "enum", values: ["a", "b"] }]), /default "c".*a, b/);
|
|
assert.match(why([{ key: "a", type: "string", values: ["a"] }]), /values.*only.*enum/);
|
|
});
|
|
|
|
test("an unset optional setting resolves to undefined, not to a stand-in", () => {
|
|
const result = resolveSettings([pluginWith("p", [{ key: "a", type: "string" }])], {});
|
|
assert.deepEqual(result.errors, []);
|
|
assert.equal(result.values.get("p")?.["a"], undefined);
|
|
});
|
|
|
|
test("a default fills in, and an env value overrides it", () => {
|
|
const plugins = [pluginWith("p", [{ default: 8, key: "maxHours", type: "number" }])];
|
|
assert.equal(resolveSettings(plugins, {}).values.get("p")?.["maxHours"], 8);
|
|
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_MAX_HOURS: "12" }).values.get("p")?.["maxHours"], 12);
|
|
});
|
|
|
|
test("an empty variable reads as unset — compose passes an unset one through as \"\"", () => {
|
|
const plugins = [pluginWith("p", [{ default: "fallback", key: "a", type: "string" }])];
|
|
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_A: "" }).values.get("p")?.["a"], "fallback");
|
|
const required = [pluginWith("p", [{ key: "a", required: true, type: "string" }])];
|
|
assert.match(resolveSettings(required, { PLUGIN_SETTING_P_A: "" }).errors.join("\n"), /must be set/);
|
|
});
|
|
|
|
test("a missing required setting is an error naming the plugin, the key and the variable", () => {
|
|
const result = resolveSettings([pluginWith("scheduling", [{ key: "timezone", required: true, type: "string" }])], {});
|
|
assert.equal(result.errors.length, 1);
|
|
assert.match(result.errors[0] ?? "", /scheduling/);
|
|
assert.match(result.errors[0] ?? "", /timezone/);
|
|
assert.match(result.errors[0] ?? "", /PLUGIN_SETTING_SCHEDULING_TIMEZONE/);
|
|
});
|
|
|
|
test("each type coerces from the environment, and a bad value fails loud", () => {
|
|
const decls: SettingDecl[] = [
|
|
{ key: "text", type: "string" },
|
|
{ key: "count", type: "number" },
|
|
{ key: "flag", type: "boolean" },
|
|
{ key: "mode", type: "enum", values: ["strict", "lenient"] },
|
|
{ key: "base", type: "url" },
|
|
];
|
|
const ok = resolveSettings([pluginWith("p", decls)], {
|
|
PLUGIN_SETTING_P_BASE: "https://example.com/v1",
|
|
PLUGIN_SETTING_P_COUNT: "42",
|
|
PLUGIN_SETTING_P_FLAG: "true",
|
|
PLUGIN_SETTING_P_MODE: "strict",
|
|
PLUGIN_SETTING_P_TEXT: "hello",
|
|
});
|
|
assert.deepEqual(ok.errors, []);
|
|
assert.deepEqual(ok.values.get("p"), { base: "https://example.com/v1", count: 42, flag: true, mode: "strict", text: "hello" });
|
|
|
|
const bad = resolveSettings([pluginWith("p", decls)], {
|
|
PLUGIN_SETTING_P_BASE: "not a url",
|
|
PLUGIN_SETTING_P_COUNT: "twelve",
|
|
PLUGIN_SETTING_P_FLAG: "yes",
|
|
PLUGIN_SETTING_P_MODE: "loose",
|
|
});
|
|
assert.equal(bad.errors.length, 4);
|
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number/);
|
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false"/);
|
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient/);
|
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/);
|
|
});
|
|
|
|
test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => {
|
|
const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])];
|
|
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false);
|
|
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "0" }).errors.length, 1);
|
|
});
|
|
|
|
test("REQUIRE_SECURE_SECRETS refuses an unset secret and one still on its dev default", () => {
|
|
const decls: SettingDecl[] = [{ default: "dev-insecure", key: "apiKey", secret: true, type: "string" }];
|
|
const plugins = [pluginWith("p", decls)];
|
|
assert.deepEqual(resolveSettings(plugins, {}).errors, []); // off: the dev default boots a clean clone
|
|
|
|
assert.match(resolveSettings(plugins, {}, { requireSecureSecrets: true }).errors.join("\n"), /apiKey.*must be set/);
|
|
assert.match(
|
|
resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "dev-insecure" }, { requireSecureSecrets: true }).errors.join("\n"),
|
|
/apiKey.*dev/,
|
|
);
|
|
assert.deepEqual(resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "real" }, { requireSecureSecrets: true }).errors, []);
|
|
});
|
|
|
|
test("a secret's value reaches the plugin but never the catalog", () => {
|
|
const plugins = [pluginWith("p", [{ key: "apiKey", secret: true, type: "string" }])];
|
|
const result = resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "s3cr3t" });
|
|
assert.equal(result.values.get("p")?.["apiKey"], "s3cr3t");
|
|
|
|
const entry = result.catalog[0]?.settings[0];
|
|
assert.equal(entry?.secret, true);
|
|
assert.equal(entry?.source, "env");
|
|
assert.equal(entry?.value, undefined); // not the value, not its length, not a mask of it
|
|
assert.ok(!JSON.stringify(result.catalog).includes("s3cr3t"));
|
|
});
|
|
|
|
test("the catalog carries every installed plugin, so \"declares none\" is not \"not installed\"", () => {
|
|
const plugins = [pluginWith("with", [{ default: "x", key: "a", type: "string" }]), { apiVersion: "0.2.0", id: "without" }];
|
|
const catalog = resolveSettings(plugins, {}).catalog;
|
|
assert.deepEqual(catalog.map((entry) => entry.pluginId), ["with", "without"]);
|
|
assert.deepEqual(catalog[1]?.settings, []);
|
|
});
|
|
|
|
test("a catalog entry carries the variable to set and where the value came from", () => {
|
|
const decls: SettingDecl[] = [
|
|
{ description: "Where shifts come from", key: "upstream", required: true, type: "url" },
|
|
{ default: 8, key: "maxHours", type: "number" },
|
|
{ key: "note", type: "string" },
|
|
];
|
|
const catalog = resolveSettings([pluginWith("scheduling", decls)], { PLUGIN_SETTING_SCHEDULING_UPSTREAM: "https://x.test" }).catalog;
|
|
assert.deepEqual(catalog[0]?.settings, [
|
|
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://x.test" },
|
|
{ envName: "PLUGIN_SETTING_SCHEDULING_MAX_HOURS", key: "maxHours", required: false, secret: false, source: "default", type: "number", value: "8" },
|
|
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
|
|
]);
|
|
});
|
|
|
|
test("a variable no plugin declares is reported, never acted on", () => {
|
|
const declared = settingsEnvNames([pluginWith("scheduling", [{ key: "timezone", type: "string" }])]);
|
|
const strays = strayNames(
|
|
{ PATH: "/usr/bin", PLUGIN_DB_URL: "postgres://x", PLUGIN_SETTING_GONE_KEY: "x", PLUGIN_SETTING_SCHEDULING_TIMEZOME: "UTC", PLUGIN_SETTING_SCHEDULING_TIMEZONE: "UTC" },
|
|
declared,
|
|
);
|
|
assert.deepEqual(strays, ["PLUGIN_SETTING_GONE_KEY", "PLUGIN_SETTING_SCHEDULING_TIMEZOME"]); // sorted; the host's own untouched
|
|
});
|