tests/settings.test.ts
Rama main · réplica de lectura del repositorio original.
// Settings validation (server/settings.ts). PATCH /api/settings is the one
// admin-authenticated route that writes arbitrary JSON into a file the whole
// site reads on every request, so its allowlist is a security boundary as much
// as a schema: an unknown key is a 400, a prototype key is a 400, and a patch
// that fails anywhere lands nothing at all.
import assert from "node:assert/strict";
import { readFileSync, utimesSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { after, before, beforeEach, describe, it } from "node:test";
import {
effectiveSettings,
getSettings,
inheritedSettings,
moveFolderIcons,
patchSettings,
setAdminTheme,
} from "../server/settings.ts";
import { initSite } from "../server/site.ts";
import { initVault, VaultError } from "../server/vault.ts";
import { makeDir, makeVault, removeVault } from "./helpers/vault.ts";
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const data = makeDir();
const root = makeVault({
"Home.md": "# Home\n",
"attachments/logo.png": "png",
"attachments/favicon.ico": "ico",
"attachments/notes.pdf": "pdf",
".obsidian/icon.png": "png",
});
before(() => {
initSite({ ASTROLABE_DATA: data });
initVault(root);
});
after(() => {
removeVault(root);
removeVault(data);
});
beforeEach(() => {
// Every test starts from an empty stored file (env defaults in effect).
patchSettings({
siteName: null,
tagline: null,
footer: null,
defaultTheme: null,
publicLayout: null,
blogLocale: null,
language: null,
languageFilter: null,
languageToggle: null,
commentsEnabled: null,
shareButtons: null,
ambient: null,
excludeTags: null,
favicon: null,
logo: null,
home: null,
folderIcons: null,
publicFolders: null,
});
});
/** The 400 message a rejected patch answers with, or "" when it was accepted. */
function refuse(patch: Record<string, unknown>): string {
try {
patchSettings(patch);
return "";
} catch (err) {
assert.ok(err instanceof VaultError, `expected a VaultError, got ${String(err)}`);
assert.equal(err.status, 400);
return err.message;
}
}
describe("the allowlist", () => {
it("refuses any key it does not know", () => {
for (const key of ["nope", "adminPasswordHash", "sessionSecret", "port", "vaultPath", "SITE_NAME"]) {
assert.match(refuse({ [key]: "x" }), /Unknown settings key/, `accepted ${key}`);
}
});
it("refuses inherited Object.prototype names as keys", () => {
// JSON.parse gives these as OWN properties, which is exactly how they
// arrive from a request body — `in` would have resolved them up the chain.
for (const key of ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"]) {
const patch = JSON.parse(`{"${key}": {"polluted": true}}`) as Record<string, unknown>;
assert.match(refuse(patch), /Unknown settings key/, `accepted ${key}`);
}
assert.equal(({} as Record<string, unknown>).polluted, undefined, "prototype was polluted");
});
it("refuses an unknown key inside home", () => {
assert.match(refuse({ home: { mode: "note", nope: 1 } }), /Unknown settings key: home\.nope/);
assert.match(refuse({ home: JSON.parse('{"__proto__": 1}') }), /Unknown settings key: home\./);
});
it("lands NOTHING when any key in the patch is bad", () => {
patchSettings({ siteName: "Before" });
assert.match(refuse({ siteName: "After", defaultTheme: "not-a-theme" }), /defaultTheme/);
assert.equal(getSettings().siteName, "Before", "a partial patch was persisted");
});
});
describe("enum keys", () => {
it("defaultTheme accepts the built-in themes and the follow sentinel", () => {
for (const theme of ["iron-gall", "void", "lapis", "parchment"]) {
patchSettings({ defaultTheme: theme });
assert.equal(getSettings().defaultTheme, theme);
}
// "follow" is not a theme, it is the RULE — visitors get whatever theme
// the admin is editing in (settings.adminTheme). It has to be STORABLE
// rather than merely absent: an instance whose .env pins DEFAULT_THEME
// needs a value that overrides that pin, and clearing the key would only
// fall back to it.
patchSettings({ defaultTheme: "follow" });
assert.equal(getSettings().defaultTheme, "follow");
assert.match(refuse({ defaultTheme: "midnight" }), /must be/);
});
it("adminTheme is the mirror of the admin's own editor theme", () => {
// Written by POST /api/theme, never by a settings PATCH: it is a fact
// about the owner's browser, not a policy. It survives a pin, so
// unpinning puts visitors back on the theme the owner is actually using.
setAdminTheme("void");
patchSettings({ defaultTheme: "solar" });
assert.equal(getSettings().adminTheme, "void", "a pin must not clear the mirror");
assert.equal(effectiveSettings().visitorTheme, "solar", "the pin is what visitors get");
patchSettings({ defaultTheme: "follow" });
assert.equal(effectiveSettings().visitorTheme, "void", "following serves the mirror");
assert.equal(setAdminTheme("void"), false, "an unchanged mirror must not rewrite the file");
assert.throws(() => setAdminTheme("midnight"), /must be one of/);
});
it("lowercases the theme id, because the env door does too", () => {
// This case used to assert the opposite — that "Iron-Gall" was a 400. It
// is a deliberate change, not a regression: `DEFAULT_THEME` is lowercased
// by readEnvTheme() before validation, so `DEFAULT_THEME=SOLAR` booted the
// instance on solar while `PATCH {"defaultTheme":"SOLAR"}` answered 400 —
// one value accepted at one door and refused at the other. Theme ids are a
// closed lowercase enum, so both doors now coerce to the canonical form.
patchSettings({ defaultTheme: "Iron-Gall" });
assert.equal(getSettings().defaultTheme, "iron-gall");
});
it("the server and the client read ONE theme list", () => {
// This used to compare two hand-maintained lists and warn when they drifted
// — the server could not import the client store, so the same enum was
// written twice. It is written once now, in shared/themes.ts, and both
// sides import it; the drift this case guarded against is no longer
// expressible. So the case guards the ARRANGEMENT that made it impossible.
const serverSrc = readFileSync(path.join(repo, "server/settings.ts"), "utf8");
const clientSrc = readFileSync(path.join(repo, "client/state.ts"), "utf8");
const sharedSrc = readFileSync(path.join(repo, "shared/themes.ts"), "utf8");
assert.match(
serverSrc,
/import \{[^}]*THEMES as THEME_IDS[^}]*\} from "\.\.\/shared\/themes\.ts"/,
"the server must take its theme list from shared/themes.ts",
);
assert.match(
clientSrc,
/from "\.\/themes\.ts"/,
"the client must take its theme list from shared/themes.ts",
);
assert.match(
sharedSrc,
/export const THEMES = \[\.\.\.DARK_THEMES, \.\.\.LIGHT_THEMES\]/,
"shared/themes.ts must remain the one definition",
);
});
it("publicLayout accepts app/blog/designed, case-insensitively", () => {
patchSettings({ publicLayout: "BLOG" });
assert.equal(getSettings().publicLayout, "blog");
// "designed" is the third public shell (the site design engine). It was
// not a value when this case was written; it is one now, and a case that
// still asserted a two-value enum would fail the moment the engine landed.
patchSettings({ publicLayout: "DESIGNED" });
assert.equal(getSettings().publicLayout, "designed");
assert.match(refuse({ publicLayout: "magazine" }), /must be/);
});
it("language accepts en/ar only", () => {
patchSettings({ language: "AR" });
assert.equal(getSettings().language, "ar");
assert.match(refuse({ language: "fr" }), /must be "en" or "ar"/);
});
it("home.mode accepts note/dashboard, and 'note' means 'stored default'", () => {
patchSettings({ home: { mode: "dashboard" } });
assert.equal(getSettings().home?.mode, "dashboard");
patchSettings({ home: { mode: "note" } });
assert.equal(getSettings().home?.mode, undefined, "the default is not stored");
assert.match(refuse({ home: { mode: "grid" } }), /home\.mode/);
});
it("booleans are booleans, not strings", () => {
// languageFilter is NOT in this list any more: it grew from a boolean into
// a four-value enum (off / follow / ar / en), so "must be a boolean" is no
// longer the thing it says. Its own case is below.
for (const key of ["languageToggle", "commentsEnabled", "shareButtons", "ambient"]) {
assert.match(refuse({ [key]: "true" }), /must be a boolean or null/, `accepted "${key}": "true"`);
assert.match(refuse({ [key]: 1 }), /must be a boolean or null/);
patchSettings({ [key]: true });
}
assert.equal(getSettings().commentsEnabled, true);
// The one in this list whose EFFECTIVE default is off rather than on, so
// absence and `false` are the same answer and only `true` is a decision.
assert.equal(effectiveSettings().ambient, true);
patchSettings({ ambient: null });
assert.equal(effectiveSettings().ambient, false, "absence is off");
});
it("languageFilter is an enum, and still takes the booleans it used to", () => {
for (const mode of ["off", "follow", "ar", "en"]) {
patchSettings({ languageFilter: mode });
assert.equal(getSettings().languageFilter, mode);
}
assert.match(refuse({ languageFilter: "fr" }), /must be one of/);
assert.match(refuse({ languageFilter: 1 }), /must be one of/);
});
});
describe("per-key validators", () => {
it("caps string lengths per key, not with one global budget", () => {
assert.match(refuse({ siteName: "x".repeat(81) }), /too long \(80/);
assert.match(refuse({ tagline: "x".repeat(161) }), /too long \(160/);
assert.match(refuse({ footer: "x".repeat(201) }), /too long \(200/);
patchSettings({ siteName: "x".repeat(80) });
assert.equal(getSettings().siteName?.length, 80);
});
it("strips control characters and trims", () => {
patchSettings({ siteName: " A\tB\nC " });
assert.equal(getSettings().siteName, "A B C");
});
it("clears a key with null or an empty string", () => {
patchSettings({ siteName: "Set" });
patchSettings({ siteName: "" });
assert.equal(getSettings().siteName, undefined);
patchSettings({ siteName: "Set" });
patchSettings({ siteName: null });
assert.equal(getSettings().siteName, undefined);
patchSettings({ siteName: " " });
assert.equal(getSettings().siteName, undefined, "whitespace-only clears too");
});
it("rejects a non-string where a string belongs", () => {
assert.match(refuse({ siteName: 42 }), /must be a string or null/);
assert.match(refuse({ siteName: { a: 1 } }), /must be a string or null/);
});
it("validates blogLocale as BCP47 and canonicalizes it", () => {
patchSettings({ blogLocale: "en-us" });
assert.equal(getSettings().blogLocale, "en-US");
patchSettings({ blogLocale: "ar-EG-u-nu-latn" });
assert.equal(getSettings().blogLocale, "ar-EG-u-nu-latn");
assert.match(refuse({ blogLocale: "not a locale" }), /not a valid BCP47/);
assert.match(refuse({ blogLocale: "e".repeat(40) }), /too long|not a valid BCP47/);
});
it("excludeTags: strips #, dedupes, refuses junk and refuses a flood", () => {
patchSettings({ excludeTags: ["#draft", "draft", "zettel/seed", "مسودة"] });
assert.deepEqual(getSettings().excludeTags, ["draft", "zettel/seed", "مسودة"]);
assert.match(refuse({ excludeTags: "draft" }), /must be an array/);
assert.match(refuse({ excludeTags: [1] }), /must be an array of strings/);
assert.match(refuse({ excludeTags: ["has space"] }), /is not a simple tag/);
assert.match(refuse({ excludeTags: ["-leading-dash"] }), /is not a simple tag/);
assert.match(refuse({ excludeTags: ["x".repeat(51)] }), /is not a simple tag/);
assert.match(refuse({ excludeTags: new Array(201).fill("tag") }), /too many tags/);
patchSettings({ excludeTags: [] });
assert.equal(getSettings().excludeTags, undefined, "an empty array clears back to env");
});
it("favicon must be a safe vault IMAGE path", () => {
patchSettings({ favicon: "attachments/favicon.ico" });
assert.equal(getSettings().favicon, "attachments/favicon.ico");
patchSettings({ favicon: "./attachments/logo.png" });
assert.equal(getSettings().favicon, "attachments/logo.png", "normalized on the way in");
assert.match(refuse({ favicon: "attachments/notes.pdf" }), /must be a vault image path/);
assert.match(refuse({ favicon: "Home.md" }), /must be a vault image path/);
assert.match(refuse({ favicon: "../outside.png" }), /not a valid vault path/);
assert.match(refuse({ favicon: ".obsidian/icon.png" }), /not a valid vault path/);
});
it("KNOWN BUG: favicon accepts a URL and mangles it into a vault path", () => {
// cleanVaultImage has no scheme guard (cleanImageRef, which logo and
// home.banner use, does). "https://example.com/x.png" normalizes to
// "https:/example.com/x.png" — a path that passes safeAbs, ends in .png,
// and is stored. The admin sees the field accept their URL; the site then
// serves no favicon at all, and the bogus path joins settingsAssetPaths().
patchSettings({ favicon: "https://example.com/x.png" });
assert.equal(getSettings().favicon, "https:/example.com/x.png");
// A non-https scheme is mangled the same way rather than refused.
patchSettings({ favicon: "javascript:x.png" });
assert.equal(getSettings().favicon, "javascript:x.png");
});
it("logo and home.banner also accept an https URL — and only https", () => {
patchSettings({ logo: "https://example.com/logo.png" });
assert.equal(getSettings().logo, "https://example.com/logo.png");
for (const bad of [
"http://example.com/logo.png",
"javascript:alert(1)",
"data:image/png;base64,AAAA",
"vbscript:x",
"HTTP://example.com/logo.png",
]) {
assert.match(refuse({ logo: bad }), /https:\/\/ URL or a vault image path/, `accepted ${bad}`);
assert.match(refuse({ home: { banner: bad } }), /https:\/\/ URL or a vault image path/);
}
});
it("home.note must be a safe NOTE path", () => {
patchSettings({ home: { note: "Home.md" } });
assert.equal(getSettings().home?.note, "Home.md");
// A note is no longer only markdown: `.tex` and `.latex` are notes too, so
// the rule is "a note path" rather than "a markdown path". The refusal is
// still the same refusal — an image is not a home page.
patchSettings({ home: { note: "Paper.tex" } });
assert.equal(getSettings().home?.note, "Paper.tex");
assert.match(refuse({ home: { note: "attachments/logo.png" } }), /must be a note path/);
assert.match(refuse({ home: { note: "../outside.md" } }), /not a valid vault path/);
assert.match(refuse({ home: { note: ".trash/Old.md" } }), /not a valid vault path/);
assert.match(refuse({ home: { note: 7 } }), /must be a string or null/);
});
it("home merges key by key instead of replacing the object", () => {
patchSettings({ home: { mode: "dashboard", note: "Home.md" } });
patchSettings({ home: { note: null } });
assert.equal(getSettings().home?.mode, "dashboard", "an unrelated key was dropped");
assert.equal(getSettings().home?.note, undefined);
});
// The RAW value is judged before it is cleaned. `cleanValue` REPAIRS control
// characters (a run of them becomes a space), so running it first made the
// `control` reason unreachable: "med\0ia" was answered 200 and stored as the
// folder "med ia" — a folder the author never typed.
it("refuses a control character in attachments.folder instead of repairing it", () => {
// Spelled as escapes: a literal control byte in a source file is invisible.
for (const ch of ["\u0000", "\u0009", "\u001f", "\u007f"]) {
assert.match(
refuse({ attachments: { folder: `med${ch}ia` } }),
/control characters/,
`accepted U+${ch.charCodeAt(0).toString(16).padStart(4, "0")}`,
);
assert.equal(getSettings().attachments?.folder, undefined, "nothing was stored");
}
});
it("still tolerates surrounding whitespace on a good folder", () => {
patchSettings({ attachments: { folder: "media/uploads\n" } });
assert.equal(getSettings().attachments?.folder, "media/uploads");
});
// PERIODIC FORMATS ARE CHECKED AGAINST THEIR KIND. A monthly name carrying
// a day token would make a daily note under the monthly key; a yearly one
// carrying a month would make a monthly. The format is the declaration
// (shared/periodic.ts periodKindOf), so the declaration is what is checked.
it("checks a monthly and a yearly format against their kind", () => {
patchSettings({ monthlyFormat: "YYYY/[M]MM", yearlyFormat: "[Year] YYYY" });
assert.equal(getSettings().monthlyFormat, "YYYY/[M]MM");
assert.equal(getSettings().yearlyFormat, "[Year] YYYY");
assert.match(refuse({ monthlyFormat: "YYYY-MM-DD" }), /nothing finer/);
assert.match(refuse({ monthlyFormat: "YYYY" }), /must name the month/);
assert.match(refuse({ yearlyFormat: "YYYY-MM" }), /nothing finer/);
assert.match(refuse({ yearlyFormat: "MM" }), /must name the year/);
});
it("turns a monthly or yearly kind off with 'off', and reads null back", () => {
patchSettings({ monthlyFormat: "off", yearlyFormat: "none" });
assert.equal(getSettings().monthlyFormat, "");
assert.equal(effectiveSettings().monthlyFormat, null);
assert.equal(effectiveSettings().yearlyFormat, null);
patchSettings({ monthlyFormat: null, yearlyFormat: null });
assert.equal(effectiveSettings().monthlyFormat, "YYYY-MM");
assert.equal(effectiveSettings().yearlyFormat, "YYYY");
});
it("stores the monthly and yearly templates as note paths", () => {
patchSettings({ monthlyTemplate: "Templates/Monthly.md", yearlyTemplate: "Templates/Yearly.md" });
assert.equal(effectiveSettings().monthlyTemplate, "Templates/Monthly.md");
assert.equal(effectiveSettings().yearlyTemplate, "Templates/Yearly.md");
assert.match(refuse({ monthlyTemplate: "Templates" }), /note path/);
});
// OPEN ON LAUNCH: four doors or a note path; the default door is stored
// as its absence so a default instance's file is byte-for-byte what it was.
it("stores the properties card's switch only when it is off (3.40.0)", () => {
assert.equal(effectiveSettings().propsCard, true, "absence is on");
patchSettings({ propsCard: false });
assert.equal(getSettings().propsCard, false);
assert.equal(effectiveSettings().propsCard, false);
patchSettings({ propsCard: true });
assert.equal(getSettings().propsCard, undefined, "the default is not written down");
assert.match(refuse({ propsCard: "off" }), /must be a boolean or null/);
patchSettings({ propsCard: false });
patchSettings({ propsCard: null });
assert.equal(effectiveSettings().propsCard, true);
});
it("stores a launch door, a launch note, and nothing for the default", () => {
patchSettings({ launch: "sigils" });
assert.equal(getSettings().launch, "sigils");
assert.equal(effectiveSettings().launch, "sigils");
patchSettings({ launch: "Home.md" });
assert.equal(getSettings().launch, "Home.md");
patchSettings({ launch: "resume" });
assert.equal(getSettings().launch, undefined);
assert.equal(effectiveSettings().launch, "resume");
assert.match(refuse({ launch: "attachments" }), /resume, sigils, orbits, today, or a note path/);
assert.match(refuse({ launch: "/etc/passwd.md" }), /inside the vault/);
assert.match(refuse({ launch: "../x.md" }), /not a valid vault path/);
});
});
// A folder's glyph is stored as `folderIcons[<folder path>] = <icon>`, which
// makes this key two validators wearing one name: the KEY is a vault path (the
// same boundary `templatesFolder` guards — an absolute path or a traversal
// must be a 400, never a silent rewrite) and the VALUE is a closed enum (a
// glyph no build can draw would leave a folder looking unmarked forever).
describe("folderIcons", () => {
it("stores a clean folder path against a known glyph", () => {
patchSettings({ folderIcons: { "attachments": "camera", "notes/games": "gamepad" } });
assert.deepEqual(getSettings().folderIcons, {
attachments: "camera",
"notes/games": "gamepad",
});
assert.deepEqual(effectiveSettings().folderIcons, {
attachments: "camera",
"notes/games": "gamepad",
});
});
it("answers {} in `effective` when nothing is marked", () => {
assert.deepEqual(effectiveSettings().folderIcons, {});
});
it("refuses a glyph outside the closed set", () => {
assert.match(refuse({ folderIcons: { games: "bookshelf" } }), /must be one of/);
assert.match(refuse({ folderIcons: { games: 7 } }), /must be one of/);
assert.match(refuse({ folderIcons: { games: null } }), /must be one of/);
});
it("refuses a key that is not a vault-relative folder path", () => {
// An absolute path is REFUSED, not rewritten — the whole argument in
// vaultRel()'s comment. `/etc` silently becoming `etc` would mark a
// different folder from the one the caller named.
assert.match(refuse({ folderIcons: { "/etc": "book" } }), /folderIcons/);
assert.match(refuse({ folderIcons: { "../outside": "book" } }), /folderIcons/);
assert.match(refuse({ folderIcons: { "": "book" } }), /folderIcons/);
assert.match(refuse({ folderIcons: { " ": "book" } }), /folderIcons/);
});
it("refuses a key that names a note", () => {
assert.match(refuse({ folderIcons: { "Home.md": "book" } }), /names a note/);
});
it("stores the key the way the TREE spells it", () => {
// A trailing slash and surrounding whitespace are how a folder path gets
// typed, not what it is. The stored key has to match `TreeNode.path`
// exactly or the row it was meant for never finds its glyph.
patchSettings({ folderIcons: { " notes/games/ ": "gamepad" } });
assert.deepEqual(getSettings().folderIcons, { "notes/games": "gamepad" });
});
it("caps the map, like every other bulk key", () => {
const big: Record<string, string> = {};
for (let i = 0; i < 201; i++) big[`folder-${i}`] = "star";
assert.match(refuse({ folderIcons: big }), /too many folders/);
const atCap: Record<string, string> = {};
for (let i = 0; i < 200; i++) atCap[`folder-${i}`] = "star";
patchSettings({ folderIcons: atCap });
assert.equal(Object.keys(getSettings().folderIcons ?? {}).length, 200);
});
it("is REPLACED whole, so the picker's “no icon” can actually clear a row", () => {
patchSettings({ folderIcons: { a: "book", b: "leaf" } });
patchSettings({ folderIcons: { a: "book" } });
assert.deepEqual(getSettings().folderIcons, { a: "book" }, "a merging patch resurrected “b”");
patchSettings({ folderIcons: {} });
assert.equal(getSettings().folderIcons, undefined, "an empty map removes the key entirely");
patchSettings({ folderIcons: { a: "book" } });
patchSettings({ folderIcons: null });
assert.equal(getSettings().folderIcons, undefined);
});
it("drops unreadable rows on READ rather than losing the good ones", () => {
const file = path.join(data, "settings.json");
writeFileSync(
file,
JSON.stringify({
folderIcons: { good: "book", bad: "bookshelf", "../nope": "leaf", alsoGood: "moon" },
}),
);
assert.deepEqual(getSettings().folderIcons, { good: "book", alsoGood: "moon" });
});
it("carries a folder's glyph through a rename, subtree and all", () => {
patchSettings({
folderIcons: { Games: "gamepad", "Games/Finished": "star", Reading: "book" },
});
moveFolderIcons("Games", "Play");
assert.deepEqual(getSettings().folderIcons, {
Play: "gamepad",
"Play/Finished": "star",
Reading: "book",
});
});
it("carries it through a MOVE into another folder", () => {
patchSettings({ folderIcons: { Games: "gamepad" } });
moveFolderIcons("Games", "Archive/Games");
assert.deepEqual(getSettings().folderIcons, { "Archive/Games": "gamepad" });
});
it("prunes the folder and its subtree on delete", () => {
patchSettings({ folderIcons: { Games: "gamepad", "Games/Finished": "star", Reading: "book" } });
moveFolderIcons("Games", null);
assert.deepEqual(getSettings().folderIcons, { Reading: "book" });
moveFolderIcons("Reading", null);
assert.equal(getSettings().folderIcons, undefined, "the last mark removes the key");
});
it("does not rewrite the file when nothing under the path is marked", () => {
patchSettings({ folderIcons: { Reading: "book" } });
const file = path.join(data, "settings.json");
const before = readFileSync(file, "utf8");
// A rename of an unmarked folder is the overwhelmingly common case, and it
// must not bump the mtime — that invalidates the read cache for every
// other reader on the instance, on every folder rename.
moveFolderIcons("Somewhere/Else", "Elsewhere");
assert.equal(readFileSync(file, "utf8"), before);
});
it("is a no-op on a prefix that merely LOOKS like the folder", () => {
// "Games" must not drag "GamesArchive" along with it: the boundary is the
// slash, not the string.
patchSettings({ folderIcons: { Games: "gamepad", GamesArchive: "archive" } });
moveFolderIcons("Games", "Play");
assert.deepEqual(getSettings().folderIcons, { Play: "gamepad", GamesArchive: "archive" });
});
});
describe("publicFolders", () => {
const folder = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
id: "a1",
slug: "games",
title: "Games",
icon: "gamepad",
...over,
});
it("stores the list and normalizes each row", () => {
patchSettings({
publicFolders: {
enabled: true,
folders: [folder({ slug: " Games ", title: " Games ", description: " Play " })],
},
});
assert.deepEqual(getSettings().publicFolders, {
enabled: true,
folders: [{ id: "a1", slug: "games", title: "Games", icon: "gamepad", description: "Play" }],
});
});
it("refuses an unknown sub-key, naming it", () => {
assert.match(refuse({ publicFolders: { nope: 1 } }), /Unknown settings key: publicFolders\.nope/);
assert.match(refuse({ publicFolders: JSON.parse('{"__proto__": 1}') }), /Unknown settings key: publicFolders\./);
});
it("refuses two folders with one slug — a slug is a URL", () => {
assert.match(
refuse({ publicFolders: { folders: [folder(), folder({ id: "b2", title: "Gaming" })] } }),
/two folders with the slug "games"/,
);
});
it("refuses a slug the URL cannot carry, a missing title and an unknown icon", () => {
assert.match(refuse({ publicFolders: { folders: [folder({ slug: "not a slug" })] } }), /slug/);
assert.match(refuse({ publicFolders: { folders: [folder({ title: " " })] } }), /title/);
assert.match(refuse({ publicFolders: { folders: [folder({ icon: "bookshelf" })] } }), /icon/);
assert.match(
refuse({ publicFolders: { folders: [folder({ description: "x".repeat(201) })] } }),
/description/,
);
});
it("caps the list", () => {
const many = Array.from({ length: 13 }, (_, i) => folder({ id: `i${i}`, slug: `f${i}` }));
assert.match(refuse({ publicFolders: { folders: many } }), /too many folders/);
});
it("stores a sub-value only when it differs from its default", () => {
// `home` defaults to TRUE, the other two to false: storing the default
// would pin the behaviour absence already gives.
patchSettings({ publicFolders: { enabled: false, nav: false, home: true } });
assert.equal(getSettings().publicFolders, undefined);
patchSettings({ publicFolders: { home: false } });
assert.deepEqual(getSettings().publicFolders, { home: false });
});
it("keeps the list when the master switch goes off — a take-down is not a delete", () => {
patchSettings({ publicFolders: { enabled: true, folders: [folder()] } });
patchSettings({ publicFolders: { enabled: false } });
assert.equal(getSettings().publicFolders?.enabled, undefined);
assert.equal(getSettings().publicFolders?.folders?.length, 1);
});
it("replaces the list WHOLE rather than merging it", () => {
patchSettings({ publicFolders: { folders: [folder(), folder({ id: "b2", slug: "books", title: "Books" })] } });
patchSettings({ publicFolders: { folders: [folder()] } });
assert.deepEqual(getSettings().publicFolders?.folders?.map((f) => f.slug), ["games"]);
// …and an empty list clears the key rather than storing `[]`.
patchSettings({ publicFolders: { folders: [] } });
assert.equal(getSettings().publicFolders, undefined);
});
it("fills every default in effectiveSettings", () => {
assert.deepEqual(effectiveSettings().publicFolders, {
enabled: false,
nav: false,
home: true,
folders: [],
});
});
it("drops a bad row on READ without losing the good ones", () => {
const file = path.join(data, "settings.json");
writeFileSync(
file,
JSON.stringify({
publicFolders: {
enabled: true,
folders: [
{ id: "a1", slug: "games", title: "Games", icon: "gamepad" },
{ id: "b2", slug: "books", title: "Books", icon: "bookshelf" },
{ id: "c3", slug: "games", title: "Again", icon: "book" },
],
},
}),
);
assert.deepEqual(getSettings().publicFolders?.folders?.map((f) => f.slug), ["games"]);
});
});
describe("the stored file", () => {
it("preserves unknown keys written by other tooling", () => {
const file = path.join(data, "settings.json");
patchSettings({ siteName: "A" });
const raw = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
raw.someFutureKey = { kept: true };
// Write it back the way an external tool would, then patch again.
writeFileSync(file, JSON.stringify(raw, null, 2));
patchSettings({ tagline: "B" });
const reread = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
assert.deepEqual(reread.someFutureKey, { kept: true });
assert.equal(reread.siteName, "A");
assert.equal(reread.tagline, "B");
});
it("drops malformed values on READ instead of throwing", () => {
const file = path.join(data, "settings.json");
writeFileSync(
file,
JSON.stringify({ siteName: 42, defaultTheme: "nope", excludeTags: ["ok", "not ok"], language: "fr" }),
);
const settings = getSettings();
assert.equal(settings.siteName, undefined);
assert.equal(settings.defaultTheme, undefined);
assert.equal(settings.language, undefined);
assert.deepEqual(settings.excludeTags, ["ok"], "the good entries survive");
});
it("survives a corrupt file (env defaults, no crash)", () => {
const file = path.join(data, "settings.json");
writeFileSync(file, "{not json");
assert.deepEqual(getSettings(), {});
});
});
// WHAT "INHERIT" WOULD ACTUALLY DO. The panel prints a value under every
// Inherit segment, and it used to print `effective` — the stored value
// whenever one is stored. The owner of a site with `language: "ar"` saved and
// no SITE_LANG read "Inherit (ar)", picked it, and would have got an English
// site: the note described the field, not the choice. `inherited` describes
// the choice: each key absent, everything else as it is.
describe("inheritedSettings", () => {
it("names the env default, not the stored value", () => {
patchSettings({ language: "ar", publicLayout: "designed", defaultTheme: "solar" });
assert.equal(effectiveSettings().language, "ar", "the site IS Arabic while the key is stored");
assert.equal(inheritedSettings().language, "en", "clearing the key makes it English (no SITE_LANG)");
assert.equal(effectiveSettings().publicLayout, "designed");
assert.equal(inheritedSettings().publicLayout, "app");
assert.equal(effectiveSettings().defaultTheme, "solar");
assert.equal(inheritedSettings().defaultTheme, "follow", "unset DEFAULT_THEME settles to follow, as themePref() does");
});
it("agrees with effective when nothing is stored", () => {
const inh = inheritedSettings();
const eff = effectiveSettings();
assert.equal(inh.language, eff.language);
assert.equal(inh.languageFilter, eff.languageFilter);
assert.equal(inh.publicLayout, eff.publicLayout);
assert.equal(inh.defaultTheme, eff.defaultTheme);
assert.equal(inh.homeMode, eff.home.mode);
assert.equal(inh.attachmentsMode, eff.attachments.mode);
assert.equal(inh.languageToggle, eff.languageToggle);
assert.equal(inh.commentsEnabled, eff.commentsEnabled);
assert.equal(inh.shareButtons, eff.shareButtons);
assert.equal(inh.ambient, eff.ambient);
});
it("resolves the legacy LANGUAGE_FILTER=true against the site language in force", () => {
// Clearing the FILTER key leaves the LANGUAGE key where it is, so the
// legacy "site" sentinel follows the stored language, not SITE_LANG.
initSite({ ASTROLABE_DATA: data, LANGUAGE_FILTER: "true" });
try {
patchSettings({ language: "ar" });
assert.equal(inheritedSettings().languageFilter, "ar");
patchSettings({ language: null });
assert.equal(inheritedSettings().languageFilter, "en");
} finally {
initSite({ ASTROLABE_DATA: data });
}
});
});
describe("voice notes: settings.voice (3.24.0; the backend and the processor default after it)", () => {
it("defaults to the processor-friendly model, auto backend, detection and keeping the audio", () => {
patchSettings({ voice: null });
assert.equal(getSettings().voice, undefined);
assert.deepEqual(effectiveSettings().voice, { model: "small-q5_1", backend: "auto", language: "auto", keepAudio: true });
});
it("merges sub-keys, deletes a value equal to its default, and clears with null", () => {
patchSettings({ voice: { language: "ar" } });
patchSettings({ voice: { keepAudio: false } });
assert.deepEqual(getSettings().voice, { language: "ar", keepAudio: false });
patchSettings({ voice: { model: "base-q5_1" } });
assert.deepEqual(effectiveSettings().voice, { model: "base-q5_1", backend: "auto", language: "ar", keepAudio: false });
patchSettings({ voice: { model: "small-q5_1", backend: "auto", language: "auto", keepAudio: true } });
assert.equal(getSettings().voice, undefined, "every sub-key back at its default leaves no key at all");
patchSettings({ voice: { model: "off" } });
assert.equal(effectiveSettings().voice.model, "off");
patchSettings({ voice: null });
assert.equal(getSettings().voice, undefined);
});
it("stores the backend choice: cpu is kept, auto is the default's absence", () => {
patchSettings({ voice: { backend: "cpu" } });
assert.deepEqual(getSettings().voice, { backend: "cpu" });
assert.equal(effectiveSettings().voice.backend, "cpu");
patchSettings({ voice: { model: "large-v3-turbo-q5_0" } });
assert.deepEqual(getSettings().voice, { backend: "cpu", model: "large-v3-turbo-q5_0" }, "the model and the backend are two sub-keys");
patchSettings({ voice: { backend: "auto" } });
assert.deepEqual(getSettings().voice, { model: "large-v3-turbo-q5_0" });
patchSettings({ voice: { backend: null } });
assert.equal(effectiveSettings().voice.backend, "auto");
patchSettings({ voice: null });
});
it("migrates: the old implicit default follows the new one, an explicit large turbo is kept", () => {
const file = path.join(data, "settings.json");
const write = (body: unknown, at: number): void => {
writeFileSync(file, JSON.stringify(body));
// The settings cache is mtime-checked; a hand edit in the same
// millisecond would read as the old file.
utimesSync(file, at, at);
};
// An instance that never chose: the large turbo was the default and was
// stored as its absence — it gets the new default.
write({}, 1_000_000);
assert.equal(effectiveSettings().voice.model, "small-q5_1");
write({ voice: { keepAudio: false } }, 1_000_010);
assert.equal(effectiveSettings().voice.model, "small-q5_1", "other voice keys do not pin the model");
// An instance that wrote the large turbo down keeps it.
write({ voice: { model: "large-v3-turbo-q5_0" } }, 1_000_020);
assert.equal(effectiveSettings().voice.model, "large-v3-turbo-q5_0");
// And choosing it in the row from now on is written down, because it is
// no longer the default — so the next default change cannot take it.
patchSettings({ voice: null });
patchSettings({ voice: { model: "large-v3-turbo-q5_0" } });
assert.deepEqual(getSettings().voice, { model: "large-v3-turbo-q5_0" });
patchSettings({ voice: { model: "small-q5_1" } });
assert.equal(getSettings().voice, undefined, "choosing the new default is its absence");
patchSettings({ voice: null });
});
it("refuses an unknown model, backend, language or sub-key, and lands nothing", () => {
assert.match(refuse({ voice: { model: "large-v4" } }), /voice\.model/);
assert.match(refuse({ voice: { backend: "gpu" } }), /voice\.backend/);
assert.match(refuse({ voice: { language: "fr" } }), /voice\.language/);
assert.match(refuse({ voice: { keepAudio: "yes" } }), /voice\.keepAudio/);
assert.match(refuse({ voice: { engine: "onnx" } }), /voice\.engine/);
assert.match(refuse({ voice: "on" }), /voice/);
assert.equal(getSettings().voice, undefined);
});
});
describe("Discord presence: settings.presence (3.42.0)", () => {
it("absent by default, every default filled in effective, the placeholder id read as null", () => {
patchSettings({ presence: null });
assert.equal(getSettings().presence, undefined);
assert.deepEqual(effectiveSettings().presence, {
mode: "off",
clientId: null,
idleMinutes: 10,
idle: "clear",
vaultName: false,
elapsed: "subject",
button: false,
server: false,
assets: "site",
});
});
it("merges sub-keys, deletes a value equal to its default, a null sub-key, and the whole key on null", () => {
patchSettings({ presence: { mode: "title", clientId: " 123456789012345678 " } });
patchSettings({ presence: { idleMinutes: 30, idle: "show", vaultName: true, elapsed: "session", button: true, server: true, assets: "uploaded" } });
assert.deepEqual(getSettings().presence, {
mode: "title",
clientId: "123456789012345678",
idleMinutes: 30,
idle: "show",
vaultName: true,
elapsed: "session",
button: true,
server: true,
assets: "uploaded",
});
assert.equal(effectiveSettings().presence.clientId, "123456789012345678");
patchSettings({ presence: { idleMinutes: 10, idle: "clear", vaultName: false, elapsed: "subject", button: false, server: false, assets: "site" } });
assert.deepEqual(getSettings().presence, { mode: "title", clientId: "123456789012345678" });
patchSettings({ presence: { clientId: null, mode: "off" } });
assert.equal(getSettings().presence, undefined, "every sub-key back at its default leaves no key at all");
patchSettings({ presence: { mode: "activity" } });
patchSettings({ presence: null });
assert.equal(getSettings().presence, undefined);
});
it("refuses every malformed value and unknown sub-key, and lands nothing", () => {
patchSettings({ presence: { mode: "activity" } });
assert.match(refuse({ presence: "on" }), /presence/);
assert.match(refuse({ presence: [] }), /presence/);
assert.match(refuse({ presence: { mode: "loud" } }), /presence\.mode/);
assert.match(refuse({ presence: { clientId: "12345" } }), /presence\.clientId/);
assert.match(refuse({ presence: { clientId: "PASTE_YOUR_DISCORD_APPLICATION_ID" } }), /presence\.clientId/);
assert.match(refuse({ presence: { clientId: 123456789012345678 } }), /presence\.clientId/);
assert.match(refuse({ presence: { idleMinutes: 0 } }), /presence\.idleMinutes/);
assert.match(refuse({ presence: { idleMinutes: 121 } }), /presence\.idleMinutes/);
assert.match(refuse({ presence: { idleMinutes: 2.5 } }), /presence\.idleMinutes/);
assert.match(refuse({ presence: { idle: "hide" } }), /presence\.idle/);
assert.match(refuse({ presence: { vaultName: "yes" } }), /presence\.vaultName/);
assert.match(refuse({ presence: { button: 1 } }), /presence\.button/);
assert.match(refuse({ presence: { server: "true" } }), /presence\.server/);
assert.match(refuse({ presence: { elapsed: "always" } }), /presence\.elapsed/);
assert.match(refuse({ presence: { assets: "cdn" } }), /presence\.assets/);
assert.match(refuse({ presence: { colour: "gold" } }), /Unknown settings key: presence\.colour/);
assert.match(refuse({ presence: { mode: "title", nope: 1 } }), /presence\.nope/);
assert.deepEqual(getSettings().presence, { mode: "activity" }, "a refused patch lands nothing");
patchSettings({ presence: null });
});
it("a hand-edited file with malformed sub-keys reads them as absent", () => {
const file = path.join(data, "settings.json");
writeFileSync(file, JSON.stringify({ presence: { mode: "loud", clientId: "nope", idleMinutes: 999, button: true, elapsed: "off" } }));
utimesSync(file, 2_000_000, 2_000_000);
assert.deepEqual(getSettings().presence, { button: true, elapsed: "off" });
assert.equal(effectiveSettings().presence.mode, "off");
patchSettings({ presence: null });
});
});