// 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 { 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; assert.match(refuse(patch), /Unknown settings key/, `accepted ${key}`); } assert.equal(({} as Record).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[] = `, 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 = {}; for (let i = 0; i < 201; i++) big[`folder-${i}`] = "star"; assert.match(refuse({ folderIcons: big }), /too many folders/); const atCap: Record = {}; 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 = {}): Record => ({ 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; 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; 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 }); }); });