fix(housekeeping): secure radio settings cache

This commit is contained in:
Simo committed 2026-09-05 15:40:43 +02:00
1 parent 845666cf37
commit f24adfcf66
13 files changed
+796 -188

No files matched your search

+46 -32
View File
@@ -2,11 +2,12 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, RadioBanners, RadioRanks, WebsiteSetting } from "@/lib/db"; import { db, RadioBanners, RadioRanks } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -39,22 +40,32 @@ function bool(raw: FormDataEntryValue | null): boolean {
* siteSettings cache so the public radio pages pick the change up immediately. * siteSettings cache so the public radio pages pick the change up immediately.
*/ */
export async function saveRadioSetting(formData: FormData): Promise<void> { export async function saveRadioSetting(formData: FormData): Promise<void> {
await requirePermission(PERMS.RADIO_EDIT); const staff = await requirePermission(PERMS.RADIO_EDIT);
const key = str(formData.get("key")).trim().slice(0, 255); const key = str(formData.get("key")).trim();
const value = str(formData.get("value")); const value = str(formData.get("value"));
const comment = str(formData.get("comment")).trim().slice(0, 255); const comment = str(formData.get("comment")).trim();
if (!key) return;
let partial = false;
try { try {
await db const snapshot = await executeLegacyHotelMutation(
.insert(WebsiteSetting) staff,
.values({ key, value, comment: comment || null }) "radio.settings.save-one",
.onDuplicateKeyUpdate({ set: { value } }); { key, value, comment },
siteSettings.reload(); );
} catch (err) { partial = snapshot.completion?.cache === "unavailable";
logger.error("Failed to save radio setting", { err, key }); } catch {
return redirect("/admin/radio/settings?error=1");
} }
revalidatePath("/admin/radio/settings"); try {
revalidatePath("/admin/radio/settings");
} catch {
partial = true;
}
redirect(
partial
? "/admin/radio/settings?partial=1"
: "/admin/radio/settings?saved=1",
);
} }
/** /**
@@ -63,29 +74,32 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
* only touch those (and never wipe unrelated settings). * only touch those (and never wipe unrelated settings).
*/ */
export async function saveRadioSettings(formData: FormData): Promise<void> { export async function saveRadioSettings(formData: FormData): Promise<void> {
await requirePermission(PERMS.RADIO_EDIT); const staff = await requirePermission(PERMS.RADIO_EDIT);
const keysRaw = str(formData.get("__keys")); const keysRaw = str(formData.get("__keys"));
const keys = keysRaw const keys = keysRaw.split(",").map((key) => key.trim());
.split(",") const entries = keys.map((key) => ({ key, value: str(formData.get(key)) }));
.map((k) => k.trim())
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
if (keys.length === 0) return;
let partial = false;
try { try {
await Promise.all( const snapshot = await executeLegacyHotelMutation(
keys.map((key) => { staff,
const value = str(formData.get(key)); "radio.settings.save-many",
return db { entries },
.insert(WebsiteSetting)
.values({ key, value, comment: null })
.onDuplicateKeyUpdate({ set: { value } });
}),
); );
siteSettings.reload(); partial = snapshot.completion?.cache === "unavailable";
} catch (err) { } catch {
logger.error("Failed to bulk-save radio settings", { err, keys }); return redirect("/admin/radio/settings?error=1");
} }
revalidatePath("/admin/radio/settings"); try {
revalidatePath("/admin/radio/settings");
} catch {
partial = true;
}
redirect(
partial
? "/admin/radio/settings?partial=1"
: "/admin/radio/settings?saved=1",
);
} }
// ── Radio banners CRUD (radio_banners) ───────────────────────────────────── // ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
+22 -74
View File
@@ -2,93 +2,41 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
// Radio listener-points settings (website_settings radio_points_* keys).
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
// website_settings that reward listeners for time spent on the radio. Booleans
// use the string '0' / '1'. Busts the siteSettings cache so the public radio
// pages pick the change up immediately.
const POINTS_KEYS = [
"radio_points_enabled",
"radio_points_per_minute",
"radio_points_currency",
"radio_points_max_per_day",
"radio_points_min_listeners",
] as const;
const ALLOWED_CURRENCIES = new Set([
"credits",
"duckets",
"diamonds",
"points",
]);
function str(raw: FormDataEntryValue | null): string { function str(raw: FormDataEntryValue | null): string {
return typeof raw === "string" ? raw : ""; return typeof raw === "string" ? raw : "";
} }
/** Checkbox/select truthiness → '1' / '0'. */
function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
const v = str(raw).trim().toLowerCase();
return v === "1" || v === "true" || v === "on" ? "1" : "0";
}
/** Clamp a form value to a non-negative integer string (defaulting to 0). */
function intStr(raw: FormDataEntryValue | null): string {
const n = Number(str(raw).trim());
if (!Number.isFinite(n) || n < 0) return "0";
return String(Math.floor(n));
}
export async function savePoints(formData: FormData): Promise<void> { export async function savePoints(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.RADIO_EDIT); const staff = await requirePermission(PERMS.RADIO_EDIT);
const values = {
const currencyRaw = str(formData.get("radio_points_currency")) radio_points_enabled: str(formData.get("radio_points_enabled")),
.trim() radio_points_per_minute: str(formData.get("radio_points_per_minute")),
.toLowerCase(); radio_points_currency: str(formData.get("radio_points_currency")),
const currency = ALLOWED_CURRENCIES.has(currencyRaw) radio_points_max_per_day: str(formData.get("radio_points_max_per_day")),
? currencyRaw radio_points_min_listeners: str(formData.get("radio_points_min_listeners")),
: "credits";
const values: Record<(typeof POINTS_KEYS)[number], string> = {
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
radio_points_currency: currency,
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
radio_points_min_listeners: intStr(
formData.get("radio_points_min_listeners"),
),
}; };
let partial = false;
try { try {
await Promise.all( const snapshot = await executeLegacyHotelMutation(
POINTS_KEYS.map((key) => staff,
db "radio.points.save",
.insert(WebsiteSetting) values,
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
.values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
set: { value: values[key] },
}),
),
); );
siteSettings.reload(); partial = snapshot.completion?.cache === "unavailable";
await logStaffActivity({
staffId: staff.id,
action: "radio_points_update",
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
});
} catch { } catch {
// DB unavailable — fail soft so the action does not throw. return redirect("/admin/radio/points?error=1");
} }
try {
revalidatePath("/admin/radio/points"); revalidatePath("/admin/radio/points");
redirect("/admin/radio/points?saved=1"); } catch {
partial = true;
}
redirect(
partial ? "/admin/radio/points?partial=1" : "/admin/radio/points?saved=1",
);
} }
+145
View File
@@ -0,0 +1,145 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { executeLegacyHotelMutation } from "@/features/housekeeping/domains/hotel/services/mutations";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
revalidate: vi.fn(),
redirect: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidate }));
vi.mock("next/navigation", () => ({ redirect: mocks.redirect }));
vi.mock("@/lib/permissions", async () => import("@/lib/permission-slugs"));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: async () => ({ id: 42, rank: 7, username: "operator" }),
}));
vi.mock("@/features/housekeeping/domains/hotel/services/mutations", () => ({
executeLegacyHotelMutation: mocks.execute,
}));
import { saveRadioSetting, saveRadioSettings } from "./admin-radio-extra";
import { savePoints } from "./admin-radio-points";
function form(entries: Record<string, string>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(entries)) data.set(key, value);
return data;
}
beforeEach(() => {
vi.clearAllMocks();
mocks.execute.mockResolvedValue({ before: null, after: { saved: true } });
});
describe("legacy radio setting actions", () => {
it("delegates one setting with its useful metadata", async () => {
await saveRadioSetting(
form({ key: "radio_name", value: "Test Radio", comment: "Display name" }),
);
expect(executeLegacyHotelMutation).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 }),
"radio.settings.save-one",
{ key: "radio_name", value: "Test Radio", comment: "Display name" },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/radio/settings");
expect(redirect).toHaveBeenCalledWith("/admin/radio/settings?saved=1");
});
it("passes forged bulk keys to strict Hotel validation instead of silently filtering them", async () => {
mocks.execute.mockRejectedValueOnce(new Error("validation"));
await saveRadioSettings(
form({
__keys: "radio_name,cms_secret",
radio_name: "Test Radio",
cms_secret: "must-not-write",
}),
);
expect(executeLegacyHotelMutation).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 }),
"radio.settings.save-many",
{
entries: [
{ key: "radio_name", value: "Test Radio" },
{ key: "cms_secret", value: "must-not-write" },
],
},
);
expect(revalidatePath).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/radio/settings?error=1");
});
it("submits all 102 curated-size settings in one bounded Hotel operation", async () => {
const keys = Array.from(
{ length: 102 },
(_, index) => `radio_key_${index}`,
);
const data = form({
__keys: keys.join(","),
...Object.fromEntries(keys.map((key) => [key, `value-${key}`])),
});
await saveRadioSettings(data);
expect(executeLegacyHotelMutation).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 }),
"radio.settings.save-many",
{
entries: keys.map((key) => ({ key, value: `value-${key}` })),
},
);
expect(redirect).toHaveBeenCalledWith("/admin/radio/settings?saved=1");
});
it("shows a partial notice after a committed write whose cache invalidation failed", async () => {
mocks.execute.mockResolvedValueOnce({
before: null,
after: { key: "radio_name" },
completion: {
status: "partial",
external: "not-required",
audit: "persisted",
cache: "unavailable",
},
});
await saveRadioSetting(form({ key: "radio_name", value: "Test" }));
expect(redirect).toHaveBeenCalledWith("/admin/radio/settings?partial=1");
});
});
describe("legacy radio points action", () => {
const validPoints = {
radio_points_enabled: "1",
radio_points_per_minute: "2",
radio_points_currency: "duckets",
radio_points_max_per_day: "200",
radio_points_min_listeners: "3",
};
it("delegates the complete points form to the Hotel operation", async () => {
await savePoints(form(validPoints));
expect(executeLegacyHotelMutation).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 }),
"radio.points.save",
validPoints,
);
expect(redirect).toHaveBeenCalledWith("/admin/radio/points?saved=1");
});
it("never reports saved after a failed write", async () => {
mocks.execute.mockRejectedValueOnce(new Error("database unavailable"));
await savePoints(form(validPoints));
expect(revalidatePath).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledTimes(1);
expect(redirect).toHaveBeenCalledWith("/admin/radio/points?error=1");
});
});
+18 -8
View File
@@ -19,11 +19,23 @@ const CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
export default async function AdminRadioPointsPage({ export default async function AdminRadioPointsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ saved?: string }>; searchParams: Promise<{
saved?: string;
partial?: string;
error?: string;
}>;
}) { }) {
const t = await getTranslations("pages.admin.radio"); const t = await getTranslations("pages.admin.radio");
const { saved } = await searchParams; const { saved, partial, error } = await searchParams;
const notice =
error === "1"
? "Radio points settings were not saved. Check the submitted fields and try again."
: partial === "1"
? "Radio points settings were saved, but cached data could not be refreshed. Refresh the page before saving again."
: saved === "1"
? t("pointsForm.saved")
: null;
const values = { const values = {
radio_points_enabled: await siteSettings radio_points_enabled: await siteSettings
@@ -61,12 +73,10 @@ export default async function AdminRadioPointsPage({
return ( return (
<main> <main>
<section className="mt-6"> <section className="mt-6">
{saved === "1" ? ( {notice ? (
<p> <div className="admin-card mb-6">
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-success-subtle)] text-[var(--admin-text)]"> <p className="text-sm m-0">{notice}</p>
{t("pointsForm.saved")} </div>
</span>
</p>
) : null} ) : null}
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
+23 -1
View File
@@ -356,8 +356,25 @@ const GROUPS: Group[] = [
const CURATED_KEYS = new Set(GROUPS.flatMap((g) => g.fields.map((f) => f.key))); const CURATED_KEYS = new Set(GROUPS.flatMap((g) => g.fields.map((f) => f.key)));
export default async function AdminRadioSettingsPage() { export default async function AdminRadioSettingsPage({
searchParams,
}: {
searchParams: Promise<{
saved?: string;
partial?: string;
error?: string;
}>;
}) {
const t = await getTranslations("pages.admin.radio"); const t = await getTranslations("pages.admin.radio");
const noticeParams = await searchParams;
const notice =
noticeParams.error === "1"
? "Radio settings were not saved. Check the submitted fields and try again."
: noticeParams.partial === "1"
? "Radio settings were saved, but cached data could not be refreshed. Refresh the page before saving again."
: noticeParams.saved === "1"
? "Radio settings saved."
: null;
let values: Map<string, string> = new Map(); let values: Map<string, string> = new Map();
let dbError = false; let dbError = false;
@@ -398,6 +415,11 @@ export default async function AdminRadioSettingsPage() {
return ( return (
<main> <main>
{notice ? (
<div className="admin-card mb-6">
<p className="text-sm m-0">{notice}</p>
</div>
) : null}
{dbError ? ( {dbError ? (
<div className="admin-card mb-6"> <div className="admin-card mb-6">
<p className="text-xs theme-text-muted dark:theme-text-muted m-0"> <p className="text-xs theme-text-muted dark:theme-text-muted m-0">
@@ -37,6 +37,7 @@ describe("Backend audit reason integrity", () => {
before: null, before: null,
after: { dispatched: true }, after: { dispatched: true },
}), }),
invalidateSiteSettings: async () => ({ invalidated: true }),
}), }),
async () => capability, async () => capability,
); );
@@ -23,6 +23,12 @@ const context = {
function dependencies() { function dependencies() {
const transactionToken = { transaction: true }; const transactionToken = { transaction: true };
const invalidateSiteSettings = vi.fn(
async (): Promise<{
invalidated: boolean;
redis: "invalidated" | "unavailable";
}> => ({ invalidated: true, redis: "invalidated" }),
);
const writeAudit = vi.fn( const writeAudit = vi.fn(
async (_entry: AuditEntry, _transaction?: unknown) => undefined, async (_entry: AuditEntry, _transaction?: unknown) => undefined,
); );
@@ -36,10 +42,12 @@ function dependencies() {
writeAudit, writeAudit,
executeOperation, executeOperation,
transaction, transaction,
invalidateSiteSettings,
adapter: createHotelProductionMutationAdapter({ adapter: createHotelProductionMutationAdapter({
transaction, transaction,
writeAudit, writeAudit,
executeOperation, executeOperation,
invalidateSiteSettings,
}), }),
}; };
} }
@@ -121,6 +129,78 @@ describe("Hotel production mutation adapter", () => {
); );
}); });
it.each([
"radio.settings.save-one",
"radio.settings.save-many",
"radio.points.save",
] as const)(
"invalidates settings only after committed %s",
async (operation) => {
const deps = dependencies();
const events: string[] = [];
deps.executeOperation.mockImplementation(async (currentOperation) => {
events.push("write");
return {
before: { operation: currentOperation, state: "before" },
after: { operation: currentOperation, state: "after" },
};
});
deps.writeAudit.mockImplementation(async () => {
events.push("audit");
});
deps.transaction.mockImplementation(async (run) => {
const result = await run(deps.transactionToken);
events.push("commit");
return result;
});
deps.invalidateSiteSettings.mockImplementation(async () => {
events.push("invalidate");
return { invalidated: true, redis: "invalidated" };
});
await deps.adapter.execute(operation, {}, context);
expect(events).toEqual(["write", "audit", "commit", "invalidate"]);
},
);
it("does not invalidate settings when the radio transaction rolls back", async () => {
const deps = dependencies();
deps.writeAudit.mockRejectedValueOnce(new Error("audit unavailable"));
await expect(
deps.adapter.execute("radio.settings.save-one", {}, context),
).rejects.toThrow("audit unavailable");
expect(deps.invalidateSiteSettings).not.toHaveBeenCalled();
});
it("reports cache invalidation failure as partial after the radio write commits", async () => {
const deps = dependencies();
deps.invalidateSiteSettings.mockResolvedValueOnce({
invalidated: false,
redis: "unavailable",
});
const result = await deps.adapter.execute(
"radio.settings.save-many",
{},
context,
);
expect(result).toMatchObject({
after: { operation: "radio.settings.save-many" },
completion: {
status: "partial",
external: "not-required",
audit: "persisted",
cache: "unavailable",
},
});
expect(deps.transaction).toHaveBeenCalledTimes(1);
expect(deps.executeOperation).toHaveBeenCalledTimes(1);
});
it("persists intent and outcome around RCON operations", async () => { it("persists intent and outcome around RCON operations", async () => {
const deps = dependencies(); const deps = dependencies();
await deps.adapter.execute( await deps.adapter.execute(
@@ -15,6 +15,12 @@ export const HOTEL_EXTERNAL_OPERATIONS = [
"room.runtime", "room.runtime",
] as const satisfies readonly HotelMutationOperation[]; ] as const satisfies readonly HotelMutationOperation[];
const SITE_SETTINGS_OPERATIONS = new Set<HotelMutationOperation>([
"radio.settings.save-one",
"radio.settings.save-many",
"radio.points.save",
]);
type TransactionToken = unknown; type TransactionToken = unknown;
export interface HotelProductionMutationDependencies { export interface HotelProductionMutationDependencies {
@@ -28,6 +34,7 @@ export interface HotelProductionMutationDependencies {
context: HotelMutationContext, context: HotelMutationContext,
transaction?: TransactionToken, transaction?: TransactionToken,
): Promise<HotelMutationSnapshot>; ): Promise<HotelMutationSnapshot>;
invalidateSiteSettings(): Promise<{ readonly invalidated: boolean }>;
} }
function auditEntry( function auditEntry(
@@ -55,7 +62,7 @@ export function createHotelProductionMutationAdapter(
return { return {
async execute(operation, input, context) { async execute(operation, input, context) {
if (operation !== "room.runtime") { if (operation !== "room.runtime") {
return dependencies.transaction(async (transaction) => { const snapshot = await dependencies.transaction(async (transaction) => {
const snapshot = await dependencies.executeOperation( const snapshot = await dependencies.executeOperation(
operation, operation,
input, input,
@@ -68,6 +75,22 @@ export function createHotelProductionMutationAdapter(
); );
return snapshot; return snapshot;
}); });
if (!SITE_SETTINGS_OPERATIONS.has(operation)) return snapshot;
try {
const invalidation = await dependencies.invalidateSiteSettings();
if (invalidation.invalidated) return snapshot;
} catch {
// The transaction is already committed; expose partial completion.
}
return {
...snapshot,
completion: {
status: "partial",
external: "not-required",
audit: "persisted",
cache: "unavailable",
},
};
} }
await dependencies.writeAudit(auditEntry(operation, context, "intent")); await dependencies.writeAudit(auditEntry(operation, context, "intent"));
@@ -128,4 +151,8 @@ export const hotelProductionMutationAdapter =
transaction, transaction,
); );
}, },
async invalidateSiteSettings() {
const { siteSettings } = await import("@/lib/services/site-settings");
return siteSettings.reload();
},
}); });
@@ -0,0 +1,165 @@
import type { SQL } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { HotelMutationFailure } from "./mutations";
const state = vi.hoisted(() => ({
rows: [] as Array<Record<string, unknown>>,
writes: [] as Array<Record<string, unknown>>,
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const database = {
select: vi.fn(() => {
const query = {
from: vi.fn(() => query),
where: vi.fn((_condition: SQL | undefined) => {
const result = Promise.resolve(state.rows) as Promise<
Array<Record<string, unknown>>
> & {
limit: (count: number) => Promise<Array<Record<string, unknown>>>;
};
result.limit = vi.fn(() => result);
return result;
}),
};
return query;
}),
insert: vi.fn(() => ({
values: (values: Record<string, unknown>) => ({
onDuplicateKeyUpdate: async () => {
state.writes.push(values);
},
}),
})),
};
return { ...schema, db: database };
});
import { executeHotelMutationOperation } from "./mutations-runtime";
const capability: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
const context = {
capability,
correlationId: "radio-runtime",
legacy: true,
};
beforeEach(() => {
state.rows = [];
state.writes = [];
});
describe("Hotel radio settings runtime", () => {
it("rejects an unrelated setting key before any write", async () => {
await expect(
executeHotelMutationOperation(
"radio.settings.save-one",
{ key: "cms_secret", value: "private" },
context,
),
).rejects.toBeInstanceOf(HotelMutationFailure);
expect(state.writes).toEqual([]);
});
it("rejects duplicate and invalid bulk entries atomically", async () => {
for (const entries of [
[
{ key: "radio_name", value: "one" },
{ key: "radio_name", value: "two" },
],
[
{ key: "radio_name", value: "one" },
{ key: "site_name", value: "forged" },
],
]) {
await expect(
executeHotelMutationOperation(
"radio.settings.save-many",
{ entries },
context,
),
).rejects.toBeInstanceOf(HotelMutationFailure);
expect(state.writes).toEqual([]);
}
});
it("accepts the complete 102-entry curated form within the 500-entry bound", async () => {
const entries = Array.from({ length: 102 }, (_, index) => ({
key: `radio_curated_${index}`,
value: `value-${index}`,
}));
await executeHotelMutationOperation(
"radio.settings.save-many",
{ entries },
context,
);
expect(state.writes).toHaveLength(102);
});
it("rejects a bulk request above 500 entries", async () => {
const entries = Array.from({ length: 501 }, (_, index) => ({
key: `radio_setting_${index}`,
value: "value",
}));
await expect(
executeHotelMutationOperation(
"radio.settings.save-many",
{ entries },
context,
),
).rejects.toBeInstanceOf(HotelMutationFailure);
expect(state.writes).toEqual([]);
});
it.each([
[
"radio.settings.save-one" as const,
{ key: "radio_stream_url", value: "https://private.example/stream" },
],
[
"radio.settings.save-many" as const,
{
entries: [{ key: "radio_name", value: "private station name" }],
},
],
[
"radio.points.save" as const,
{
radio_points_enabled: "1",
radio_points_per_minute: "17",
radio_points_currency: "credits",
radio_points_max_per_day: "34",
radio_points_min_listeners: "2",
},
],
])(
"does not expose private values in %s audit snapshots",
async (operation, input) => {
const snapshot = await executeHotelMutationOperation(
operation,
input,
context,
);
const serialized = JSON.stringify(snapshot);
for (const privateValue of [
"https://private.example/stream",
"private station name",
"17",
"34",
]) {
expect(serialized).not.toContain(privateValue);
}
},
);
});
@@ -72,12 +72,12 @@ function requireFound(
return row; return row;
} }
function isSensitiveSetting(key: string): boolean { function settingSnapshot(key: string, _value: string, comment?: string | null) {
return /secret|token|password|api[_-]?key|webhook|custom_js/i.test(key); return {
} key,
value: "[Redacted]",
function settingSnapshot(key: string, value: string) { ...(comment ? { comment } : {}),
return { key, value: isSensitiveSetting(key) ? "[Redacted]" : value }; };
} }
function safeImagePath(value: string): boolean { function safeImagePath(value: string): boolean {
@@ -127,8 +127,8 @@ const roomRuntimeSchema = z
}) })
.strict(); .strict();
const settingKey = requiredText(255).refine( const settingKey = requiredText(255).refine((key) =>
(key) => key.startsWith("radio_") || key.startsWith("auto_dj_"), /^(?:radio_|auto_dj_)[a-z0-9_]+$/i.test(key),
); );
const settingSchema = z const settingSchema = z
.object({ .object({
@@ -144,7 +144,7 @@ const settingsSchema = z
z.object({ key: settingKey, value: z.string().max(20_000) }).strict(), z.object({ key: settingKey, value: z.string().max(20_000) }).strict(),
) )
.min(1) .min(1)
.max(100), .max(500),
}) })
.strict(); .strict();
const idSchema = z.object({ id: positiveBigInt }).strict(); const idSchema = z.object({ id: positiveBigInt }).strict();
@@ -368,8 +368,10 @@ async function executeRadioMutation(
set: { value: value.value, comment: value.comment || null }, set: { value: value.value, comment: value.comment || null },
}); });
return { return {
before: before ? settingSnapshot(before.key, before.value) : null, before: before
after: settingSnapshot(value.key, value.value), ? settingSnapshot(before.key, before.value, before.comment)
: null,
after: settingSnapshot(value.key, value.value, value.comment),
}; };
} }
if (operation === "radio.settings.save-many") { if (operation === "radio.settings.save-many") {
@@ -627,7 +629,9 @@ async function executeRadioMutation(
before: { before: {
settings: before.map((entry) => settingSnapshot(entry.key, entry.value)), settings: before.map((entry) => settingSnapshot(entry.key, entry.value)),
}, },
after: { settings: entries }, after: {
settings: entries.map((entry) => settingSnapshot(entry.key, entry.value)),
},
}; };
} }
@@ -21,6 +21,7 @@ export interface HousekeepingPartialCompletion {
readonly status: "partial"; readonly status: "partial";
readonly external: "not-required" | "completed" | "failed"; readonly external: "not-required" | "completed" | "failed";
readonly audit: "persisted" | "unavailable"; readonly audit: "persisted" | "unavailable";
readonly cache?: "invalidated" | "unavailable";
} }
export type HousekeepingResult<T> = export type HousekeepingResult<T> =
+145 -5
View File
@@ -1,6 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { selectFrom, redisMocks, loggerMocks } = vi.hoisted(() => ({
selectFrom: vi.fn(),
redisMocks: {
get: vi.fn(),
setex: vi.fn(),
del: vi.fn(),
},
loggerMocks: { warn: vi.fn() },
}));
const { selectFrom } = vi.hoisted(() => ({ selectFrom: vi.fn() }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", () => ({
db: { db: {
select: () => ({ select: () => ({
@@ -14,27 +23,37 @@ vi.mock("@/lib/db", () => ({
}, },
WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" }, WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" },
})); }));
vi.mock("@/lib/redis", () => ({ redis: redisMocks }));
vi.mock("@/lib/logger", () => ({ logger: loggerMocks }));
import { siteSettings } from "./site-settings"; import { createSiteSettings } from "./site-settings";
beforeEach(async () => { beforeEach(() => {
selectFrom.mockReset(); selectFrom.mockReset();
await siteSettings.reload(); redisMocks.get.mockReset().mockResolvedValue(null);
redisMocks.setex.mockReset().mockResolvedValue("OK");
redisMocks.del.mockReset().mockResolvedValue(1);
loggerMocks.warn.mockReset();
}); });
afterEach(() => vi.useRealTimers());
describe("siteSettings", () => { describe("siteSettings", () => {
it("returns a value by key", async () => { it("returns a value by key", async () => {
const siteSettings = createSiteSettings(null);
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]); selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
expect(await siteSettings.get("hotel_name")).toBe("AtomHotel"); expect(await siteSettings.get("hotel_name")).toBe("AtomHotel");
}); });
it("returns the fallback when key is missing", async () => { it("returns the fallback when key is missing", async () => {
const siteSettings = createSiteSettings(null);
selectFrom.mockResolvedValue([]); selectFrom.mockResolvedValue([]);
expect(await siteSettings.get("missing", "fallback")).toBe("fallback"); expect(await siteSettings.get("missing", "fallback")).toBe("fallback");
expect(await siteSettings.get("missing")).toBeNull(); expect(await siteSettings.get("missing")).toBeNull();
}); });
it("coerces '1'/'0' string booleans", async () => { it("coerces '1'/'0' string booleans", async () => {
const siteSettings = createSiteSettings(null);
selectFrom.mockResolvedValue([ selectFrom.mockResolvedValue([
{ key: "maintenance_enabled", value: "1" }, { key: "maintenance_enabled", value: "1" },
{ key: "radio_enabled", value: "0" }, { key: "radio_enabled", value: "0" },
@@ -45,6 +64,7 @@ describe("siteSettings", () => {
}); });
it("fetches once and caches until reload", async () => { it("fetches once and caches until reload", async () => {
const siteSettings = createSiteSettings(null);
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]); selectFrom.mockResolvedValue([{ key: "hotel_name", value: "AtomHotel" }]);
await siteSettings.get("hotel_name"); await siteSettings.get("hotel_name");
await siteSettings.getBool("hotel_name"); await siteSettings.getBool("hotel_name");
@@ -53,4 +73,124 @@ describe("siteSettings", () => {
await siteSettings.get("hotel_name"); await siteSettings.get("hotel_name");
expect(selectFrom).toHaveBeenCalledTimes(2); expect(selectFrom).toHaveBeenCalledTimes(2);
}); });
it("refreshes expired memory from the database when Redis is not configured", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-05T08:00:00.000Z"));
const siteSettings = createSiteSettings(null);
selectFrom
.mockResolvedValueOnce([{ key: "hotel_name", value: "Before" }])
.mockResolvedValueOnce([{ key: "hotel_name", value: "After" }]);
expect(await siteSettings.get("hotel_name")).toBe("Before");
vi.advanceTimersByTime(60_001);
expect(await siteSettings.get("hotel_name")).toBe("After");
expect(selectFrom).toHaveBeenCalledTimes(2);
});
it("retains expired memory only when the database refresh fails", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-05T08:00:00.000Z"));
const siteSettings = createSiteSettings(null);
selectFrom
.mockResolvedValueOnce([{ key: "hotel_name", value: "Stale" }])
.mockRejectedValueOnce(new Error("database unavailable"));
expect(await siteSettings.get("hotel_name")).toBe("Stale");
vi.advanceTimersByTime(60_001);
expect(await siteSettings.get("hotel_name")).toBe("Stale");
expect(selectFrom).toHaveBeenCalledTimes(2);
expect(loggerMocks.warn).toHaveBeenCalledWith(
"Failed to load site settings from database, using fallback",
);
});
it("blocks reads during reload and discards stale Redis data from an older flight", async () => {
let resolveRedisRead!: (value: string) => void;
let resolveDelete!: (value: number) => void;
redisMocks.get.mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRedisRead = resolve;
}),
);
redisMocks.del.mockReturnValueOnce(
new Promise<number>((resolve) => {
resolveDelete = resolve;
}),
);
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "Fresh" }]);
const siteSettings = createSiteSettings(redisMocks);
const oldRead = siteSettings.get("hotel_name");
await vi.waitFor(() => expect(redisMocks.get).toHaveBeenCalledTimes(1));
const reload = siteSettings.reload();
const newRead = siteSettings.get("hotel_name");
await Promise.resolve();
expect(redisMocks.get).toHaveBeenCalledTimes(1);
resolveRedisRead(JSON.stringify({ hotel_name: "Stale Redis" }));
resolveDelete(1);
expect(await reload).toEqual({
invalidated: true,
redis: "invalidated",
});
expect(await oldRead).toBe("Fresh");
expect(await newRead).toBe("Fresh");
expect(selectFrom).toHaveBeenCalledTimes(1);
});
it("does not let an old flight clear the newer single flight", async () => {
let resolveOldDb!: (rows: Array<{ key: string; value: string }>) => void;
let resolveNewDb!: (rows: Array<{ key: string; value: string }>) => void;
selectFrom
.mockReturnValueOnce(
new Promise((resolve) => {
resolveOldDb = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveNewDb = resolve;
}),
);
const siteSettings = createSiteSettings(null);
const oldRead = siteSettings.get("hotel_name");
await vi.waitFor(() => expect(selectFrom).toHaveBeenCalledTimes(1));
await siteSettings.reload();
const newRead = siteSettings.get("hotel_name");
await vi.waitFor(() => expect(selectFrom).toHaveBeenCalledTimes(2));
resolveOldDb([{ key: "hotel_name", value: "Old" }]);
await Promise.resolve();
const joinedRead = siteSettings.get("hotel_name");
expect(selectFrom).toHaveBeenCalledTimes(2);
resolveNewDb([{ key: "hotel_name", value: "New" }]);
expect(await Promise.all([oldRead, newRead, joinedRead])).toEqual([
"New",
"New",
"New",
]);
});
it("reports Redis invalidation failure without rejection and bypasses stale Redis", async () => {
redisMocks.get.mockResolvedValueOnce(
JSON.stringify({ hotel_name: "Stale Redis" }),
);
redisMocks.del.mockRejectedValueOnce(new Error("Redis unavailable"));
selectFrom.mockResolvedValue([{ key: "hotel_name", value: "Fresh DB" }]);
const siteSettings = createSiteSettings(redisMocks);
expect(await siteSettings.get("hotel_name")).toBe("Stale Redis");
await expect(siteSettings.reload()).resolves.toEqual({
invalidated: false,
redis: "unavailable",
});
expect(await siteSettings.get("hotel_name")).toBe("Fresh DB");
expect(redisMocks.get).toHaveBeenCalledTimes(1);
expect(loggerMocks.warn).toHaveBeenCalledWith(
"Failed to invalidate Redis cache for site settings",
);
});
}); });
+106 -55
View File
@@ -13,41 +13,70 @@ const DEFAULTS: Record<string, string> = {
const CACHE_TTL_MS = 300_000; const CACHE_TTL_MS = 300_000;
const REDIS_CACHE_KEY = "site_settings"; const REDIS_CACHE_KEY = "site_settings";
// Short in-process window so repeated getters in one request (header, nav,
// footer all read logo and other settings) don't each pay a Redis round-trip.
// Redis stays the source of truth across instances.
const MEMORY_TTL_MS = 60_000; const MEMORY_TTL_MS = 60_000;
// During `next build`, pages are prerendered and `Date.now()` is treated as an
// unstable prerender value — always serve the in-process cache then (settings
// cannot change mid-build). Runtime keeps the normal TTL check.
const IS_PRERENDER = const IS_PRERENDER =
process.env.NEXT_PHASE === "phase-production-build" || process.env.NEXT_PHASE === "phase-production-build" ||
process.env.NEXT_PHASE === "phase-production-compile"; process.env.NEXT_PHASE === "phase-production-compile";
type RedisClient = Pick<NonNullable<typeof redis>, "get" | "setex" | "del">;
export interface SiteSettingsInvalidationResult {
readonly invalidated: boolean;
readonly redis: "invalidated" | "not-configured" | "unavailable";
}
class SiteSettings { class SiteSettings {
private cache: { map: Map<string, string>; expiresAt: number } | null = null; private cache: { map: Map<string, string>; expiresAt: number } | null = null;
// Single-flight: one request (re)loads the map, the rest await it — the private inFlight: {
// root layout reads settings on every render, so concurrent misses must generation: number;
// not each hammer Redis/DB (cache-stampede protection). promise: Promise<Map<string, string>>;
private inFlight: Promise<Map<string, string>> | null = null; } | null = null;
private invalidationFlight: Promise<SiteSettingsInvalidationResult> | null =
null;
private readonly pendingRedisWrites = new Set<Promise<void>>();
private generation = 0;
private bypassRedis = false;
constructor(private readonly redisClient: RedisClient | null) {}
private async loadFromDb(): Promise<Map<string, string>> { private async loadFromDb(): Promise<Map<string, string>> {
const rows = await db
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting);
return new Map(rows.map((row) => [row.key, row.value]));
}
private async writeRedis(
map: Map<string, string>,
generation: number,
): Promise<void> {
if (!this.redisClient || generation !== this.generation) return;
const write = Promise.resolve(
this.redisClient.setex(
REDIS_CACHE_KEY,
Math.ceil(CACHE_TTL_MS / 1000),
JSON.stringify(Object.fromEntries(map.entries())),
),
).then(() => undefined);
this.pendingRedisWrites.add(write);
try { try {
const rows = await db await write;
.select({ key: WebsiteSetting.key, value: WebsiteSetting.value })
.from(WebsiteSetting);
return new Map(rows.map((r) => [r.key, r.value]));
} catch { } catch {
logger.warn("Failed to load site settings from database, using defaults"); logger.warn("Failed to write site settings to Redis cache");
return new Map(Object.entries(DEFAULTS)); } finally {
this.pendingRedisWrites.delete(write);
} }
} }
private async loadFromCacheOrDb(): Promise<Map<string, string>> { private async loadForGeneration(
if (redis) { generation: number,
stale: Map<string, string> | null,
): Promise<Map<string, string>> {
if (this.redisClient && !this.bypassRedis) {
try { try {
const cached = await redis.get(REDIS_CACHE_KEY); const cached = await this.redisClient.get(REDIS_CACHE_KEY);
if (generation !== this.generation) return this.load();
if (cached) { if (cached) {
const parsed = JSON.parse(cached) as Record<string, string>; const parsed = JSON.parse(cached) as Record<string, string>;
const map = new Map(Object.entries(parsed)); const map = new Map(Object.entries(parsed));
@@ -59,42 +88,42 @@ class SiteSettings {
} }
} }
// Expired but usable fallback — keeps the site up if both Redis and DB fail. let map: Map<string, string>;
if (this.cache !== null) return this.cache.map; try {
map = await this.loadFromDb();
const map = await this.loadFromDb(); } catch {
this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; logger.warn("Failed to load site settings from database, using fallback");
if (generation !== this.generation) return this.load();
if (redis) { if (stale) return stale;
try { map = new Map(Object.entries(DEFAULTS));
const obj = Object.fromEntries(map.entries());
await redis.setex(
REDIS_CACHE_KEY,
Math.ceil(CACHE_TTL_MS / 1000),
JSON.stringify(obj),
);
} catch {
logger.warn("Failed to write site settings to Redis cache");
}
} }
if (generation !== this.generation) return this.load();
this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS };
await this.writeRedis(map, generation);
if (generation !== this.generation) return this.load();
return map; return map;
} }
private async load(): Promise<Map<string, string>> { private async load(): Promise<Map<string, string>> {
if (this.cache !== null) { while (this.invalidationFlight) await this.invalidationFlight;
if (IS_PRERENDER || this.cache.expiresAt > Date.now()) {
return this.cache.map; if (
} this.cache !== null &&
(IS_PRERENDER || this.cache.expiresAt > Date.now())
) {
return this.cache.map;
} }
if (this.inFlight) return this.inFlight; const generation = this.generation;
if (this.inFlight?.generation === generation) return this.inFlight.promise;
const run = this.loadFromCacheOrDb().finally(() => { const stale = this.cache?.map ?? null;
this.inFlight = null; let promise!: Promise<Map<string, string>>;
promise = this.loadForGeneration(generation, stale).finally(() => {
if (this.inFlight?.promise === promise) this.inFlight = null;
}); });
this.inFlight = run; this.inFlight = { generation, promise };
return run; return promise;
} }
async getAll(): Promise<Map<string, string>> { async getAll(): Promise<Map<string, string>> {
@@ -123,10 +152,10 @@ class SiteSettings {
} }
async getBool(key: string, fallback = false): Promise<boolean> { async getBool(key: string, fallback = false): Promise<boolean> {
const v = await this.get(key, null); const value = await this.get(key, null);
if (v === null) return fallback; if (value === null) return fallback;
const s = v.toLowerCase(); const normalized = value.toLowerCase();
return s === "1" || s === "true"; return normalized === "1" || normalized === "true";
} }
async update(key: string, value: string): Promise<void> { async update(key: string, value: string): Promise<void> {
@@ -137,17 +166,39 @@ class SiteSettings {
await this.reload(); await this.reload();
} }
async reload(): Promise<void> { reload(): Promise<SiteSettingsInvalidationResult> {
const generation = ++this.generation;
this.cache = null; this.cache = null;
this.inFlight = null; this.inFlight = null;
if (redis) { this.bypassRedis = true;
const pendingWrites = [...this.pendingRedisWrites];
let promise!: Promise<SiteSettingsInvalidationResult>;
promise = (async (): Promise<SiteSettingsInvalidationResult> => {
if (!this.redisClient) {
if (generation === this.generation) this.bypassRedis = false;
return { invalidated: true, redis: "not-configured" };
}
await Promise.allSettled(pendingWrites);
try { try {
await redis.del(REDIS_CACHE_KEY); await this.redisClient.del(REDIS_CACHE_KEY);
if (generation === this.generation) this.bypassRedis = false;
return { invalidated: true, redis: "invalidated" };
} catch { } catch {
logger.warn("Failed to invalidate Redis cache for site settings"); logger.warn("Failed to invalidate Redis cache for site settings");
return { invalidated: false, redis: "unavailable" };
} }
} })().finally(() => {
if (this.invalidationFlight === promise) this.invalidationFlight = null;
});
this.invalidationFlight = promise;
return promise;
} }
} }
export const siteSettings = new SiteSettings(); export function createSiteSettings(
redisClient: RedisClient | null = redis,
): SiteSettings {
return new SiteSettings(redisClient);
}
export const siteSettings = createSiteSettings();