From 437d916fc664e38228dd575122dad247d07b142d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:01:00 +0200 Subject: [PATCH] fix: guard managed setting label inventories - Treat inherited property names as unavailable settings - Preserve legacy tile aliases only for declared entries --- .../managed-record-settings-section.test.tsx | 24 +++++++++++++++ .../managed-record-settings-section.tsx | 29 +++++++++++++++---- src/lib/__tests__/insights-layout.test.ts | 11 +++++++ src/lib/insights-layout.ts | 4 ++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/components/settings/__tests__/managed-record-settings-section.test.tsx b/src/components/settings/__tests__/managed-record-settings-section.test.tsx index c34b07cf0..070f82ff1 100644 --- a/src/components/settings/__tests__/managed-record-settings-section.test.tsx +++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx @@ -125,4 +125,28 @@ describe("managed record settings forms", () => { expect(insights).not.toContain("settings.sharedRecord"); } }); + + it.each(["toString", "constructor", "__proto__"])( + "renders the unavailable label for prototype property %s", + (id) => { + const coach = render("coach", { + preferences: { + excludeMetrics: [id], + dataClusters: [id], + }, + }); + const insights = render("insights", { + layout: { + version: 2, + sections: [{ id, visible: true, order: 0 }], + tiles: [{ id, visible: true, order: 0 }], + }, + }); + + expect(coach).not.toContain(`>${id}<`); + expect(insights).toContain("Unavailable insight"); + expect(insights).not.toContain(`>${id}<`); + expect(insights).not.toContain("settings.sharedRecord"); + }, + ); }); diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 3ce399b07..76b30e62b 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -171,18 +171,30 @@ function formNumber(form: FormData, name: string, fallback: number): number { return Number.isFinite(value) ? value : fallback; } +function ownStringValue(inventory: object, id: string): string | undefined { + if (!Object.hasOwn(inventory, id)) return undefined; + const value = Reflect.get(inventory, id); + return typeof value === "string" ? value : undefined; +} + function coachMetricLabel( t: ReturnType["t"], metric: (typeof COACH_EXCLUDE_METRICS)[number], ): string { - return t(COACH_EXCLUDE_METRIC_LABEL_KEYS[metric]); + const labelKey = ownStringValue(COACH_EXCLUDE_METRIC_LABEL_KEYS, metric); + return labelKey + ? t(labelKey) + : t("settings.sharedRecord.managedSettings.insights.unknownItem"); } function coachClusterLabel( t: ReturnType["t"], cluster: (typeof COACH_DATA_CLUSTERS)[number], ): string { - return t(COACH_DATA_CLUSTER_LABEL_KEYS[cluster]); + const labelKey = ownStringValue(COACH_DATA_CLUSTER_LABEL_KEYS, cluster); + return labelKey + ? t(labelKey) + : t("settings.sharedRecord.managedSettings.insights.unknownItem"); } function insightItemLabel( @@ -191,7 +203,7 @@ function insightItemLabel( id: string, ): string { if (kind === "section") { - const labelKey = INSIGHTS_SECTION_LABEL_KEYS[id as InsightsSectionId]; + const labelKey = ownStringValue(INSIGHTS_SECTION_LABEL_KEYS, id); return labelKey ? t(labelKey) : t("settings.sharedRecord.managedSettings.insights.unknownItem"); @@ -199,9 +211,14 @@ function insightItemLabel( const normalizedId = normalizeInsightsTileId(id); if (normalizedId === "overview") return t("insights.navOverview"); - const tab = SUB_PAGE_TABS[normalizedId as keyof typeof SUB_PAGE_TABS]; - return tab - ? t(tab.labelKey) + if (!Object.hasOwn(SUB_PAGE_TABS, normalizedId)) { + return t("settings.sharedRecord.managedSettings.insights.unknownItem"); + } + + const labelKey = + SUB_PAGE_TABS[normalizedId as keyof typeof SUB_PAGE_TABS]?.labelKey; + return typeof labelKey === "string" + ? t(labelKey) : t("settings.sharedRecord.managedSettings.insights.unknownItem"); } diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 61bce8989..41f8c48c3 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -16,9 +16,20 @@ import { resolveInsightsLayout, serializeInsightsLayout, orderedVisibleSectionIds, + normalizeInsightsTileId, resolveTileLayout, } from "@/lib/insights-layout"; +describe("normalizeInsightsTileId", () => { + it("only resolves own legacy aliases", () => { + expect(normalizeInsightsTileId("blutdruck")).toBe("blood-pressure"); + + for (const id of ["toString", "constructor", "__proto__"]) { + expect(normalizeInsightsTileId(id)).toBe(id); + } + }); +}); + describe("insights-layout v2 — section id universe", () => { it("carries exactly the default sections in render order", () => { expect([...INSIGHTS_SECTION_IDS]).toEqual([ diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 1578ce75d..33cad047b 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -87,7 +87,9 @@ export const ACCEPTED_INSIGHTS_TILE_IDS = [ * resolver's known-id filter drops anything truly unrecognised. */ export function normalizeInsightsTileId(id: string): string { - return LEGACY_INSIGHTS_TILE_ID_ALIASES[id] ?? id; + return Object.hasOwn(LEGACY_INSIGHTS_TILE_ID_ALIASES, id) + ? LEGACY_INSIGHTS_TILE_ID_ALIASES[id] + : id; } /**