From 545f44c0743cac0a21e04e4110265fcdc38440f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 10:56:46 +0200 Subject: [PATCH] fix(achievements): redact hidden-locked entries from API response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hidden Easter-eggs leaked their trigger semantics through the JSON response: a curious user with DevTools open could read metric names (nightOwlCount, leapDayCount, etc.), i18n keys (hiddenNightOwl, hiddenLeapDay, etc.), icon names, and target values without unlocking anything. The opaque-card UI was already correct, but the wire shape spoiled the surprise. Project hidden+locked entries to a sentinel shape before serialising (achievements.hiddenCard.title key, HelpCircle icon, target 0, metric totalTakenIntakes), and drop hidden-only metric counters from the metrics block unless the matching achievement is unlocked. The iOS branch reuses the same redacted view so native clients can't short-circuit the surprise via the resolved-strings shape. Once an Easter-egg unlocks the real fields ship — the unlock IS the reveal moment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/hidden-redaction.test.ts | 206 ++++++++++++++++++ .../api/gamification/achievements/route.ts | 95 +++++++- 2 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts new file mode 100644 index 000000000..2b9dcbfe1 --- /dev/null +++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + passkey: { findMany: vi.fn() }, + auditLog: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + userAchievement: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "marc", + role: "USER" as const, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + heightCm: null, + locale: "en", + }, +}; + +const HIDDEN_TRIGGER_STRINGS = [ + "nightOwlCount", + "earlyBirdCount", + "leapDayCount", + "doctorPdfCount", + "localeFlipCount", + "hiddenNightOwl", + "hiddenEarlyBird", + "hiddenLeapDay", + "hiddenDoctorPdf", + "hiddenLocaleFlip", + "hiddenBugBuddy", +]; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/gamification/achievements — hidden achievement redaction", () => { + it("does NOT leak hidden trigger strings in the default JSON response when locked", async () => { + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + expect(res.status).toBe(200); + const raw = await res.text(); + for (const probe of HIDDEN_TRIGGER_STRINGS) { + expect(raw, `leak: ${probe}`).not.toContain(probe); + } + }); + + it("does NOT leak hidden trigger strings in the iOS response when locked", async () => { + const res = await GET( + new NextRequest( + "http://localhost/api/gamification/achievements?format=ios", + ), + ); + expect(res.status).toBe(200); + const raw = await res.text(); + for (const probe of HIDDEN_TRIGGER_STRINGS) { + expect(raw, `iOS leak: ${probe}`).not.toContain(probe); + } + // Also assert the resolved description text is not in the body for + // a fresh user (the iOS branch resolves keys server-side). + expect(raw).not.toContain("between 02:00 and 04:00"); + expect(raw).not.toContain("between 04:00 and 06:00"); + expect(raw).not.toContain("February 29"); + expect(raw).not.toContain("doctor report"); + expect(raw).not.toContain("Switched the app language"); + }); + + it("preserves hidden achievement entries in the response (count not zero) so the user knows hidden ones exist", async () => { + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + achievements: Array<{ + id: string; + isHidden: boolean; + unlocked: boolean; + category: string; + }>; + }; + }; + const hiddenEntries = body.data.achievements.filter( + (a) => a.category === "hidden" || a.isHidden, + ); + expect(hiddenEntries.length).toBeGreaterThan(0); + // every hidden+locked entry must be locked AND must have isHidden:true + for (const entry of hiddenEntries) { + expect(entry.isHidden).toBe(true); + expect(entry.unlocked).toBe(false); + } + }); + + it("hidden+locked entries do not include metric, titleKey, descriptionKey, or icon trigger names", async () => { + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + achievements: Array<{ + id: string; + isHidden: boolean; + unlocked: boolean; + metric?: string; + titleKey?: string; + descriptionKey?: string; + icon?: string; + target?: number; + current?: number; + progressPercent?: number; + points?: number; + }>; + }; + }; + const hiddenLocked = body.data.achievements.filter( + (a) => a.isHidden && !a.unlocked, + ); + expect(hiddenLocked.length).toBeGreaterThan(0); + for (const entry of hiddenLocked) { + // Trigger semantics scrubbed + expect(entry.metric ?? "").not.toMatch( + /(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/, + ); + // i18n keys scrubbed + expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl"); + expect(entry.titleKey ?? "").not.toContain("hiddenEarlyBird"); + expect(entry.titleKey ?? "").not.toContain("hiddenLeapDay"); + expect(entry.titleKey ?? "").not.toContain("hiddenDoctorPdf"); + expect(entry.titleKey ?? "").not.toContain("hiddenLocaleFlip"); + expect(entry.titleKey ?? "").not.toContain("hiddenBugBuddy"); + // Icon names that hint at the trigger scrubbed + expect(entry.icon ?? "").not.toMatch(/^(Moon|Sun|Sparkles|Languages)$/); + } + }); + + it("does not include hidden-only metric counters in the metrics block when hidden achievements are locked", async () => { + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metrics: Record; + }; + }; + expect(body.data.metrics).toBeDefined(); + // Hidden-only metric names must not appear as keys + for (const probe of [ + "nightOwlCount", + "earlyBirdCount", + "leapDayCount", + "doctorPdfCount", + "localeFlipCount", + ]) { + expect(body.data.metrics).not.toHaveProperty(probe); + } + }); +}); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index e58567730..e171ac655 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -9,6 +9,8 @@ import { calculateLongestStreak, evaluateAchievementsWithCompletionDates, toBerlinDayKey, + type AchievementMetrics, + type AchievementProgress, } from "@/lib/gamification/achievements"; import { buildExpansionMetricValues, @@ -835,6 +837,24 @@ export const GET = apiHandler(async (request: NextRequest) => { .filter((a) => !a.unlocked && !a.isHidden) .sort((a, b) => b.progressPercent - a.progressPercent)[0] ?? null; + // v1.4.18 reconcile — redact hidden+locked entries before serialising + // so a curious user opening DevTools/Network can't read the trigger + // semantics from `metric`, `titleKey`, `descriptionKey`, `icon`, + // `target`, `current`, or `progressPercent`. The DOM render path + // already short-circuits on `isHidden && !unlocked`; this closes the + // wire-shape leak. Once an Easter-egg unlocks, the real fields ship + // (the unlock IS the moment to reveal the surprise). + const redactedAchievements = visibleAchievements.map((a) => + redactIfHiddenLocked(a), + ); + const redactedNextAchievement = nextAchievement + ? redactIfHiddenLocked(nextAchievement) + : null; + const redactedMetrics = redactHiddenMetrics( + fullResult.metrics, + visibleAchievements, + ); + const result = { summary: { unlockedCount: visibleUnlocked.length, @@ -847,10 +867,10 @@ export const GET = apiHandler(async (request: NextRequest) => { : Math.round( (visibleUnlocked.length / visibleAchievements.length) * 100, ), - nextAchievement, + nextAchievement: redactedNextAchievement, }, - achievements: visibleAchievements, - metrics: fullResult.metrics, + achievements: redactedAchievements, + metrics: redactedMetrics, }; if (isIosFormat) { @@ -859,7 +879,11 @@ export const GET = apiHandler(async (request: NextRequest) => { userLocale: user.locale, }); const t = getServerTranslator(locale); - const ios: IosAchievement[] = result.achievements.map((a) => ({ + // Same redaction principle: iOS clients receive opaque + // placeholders for hidden+locked entries. The native client + // mirrors the web-card opaque-placeholder UI when it sees the + // sentinel id `achievements.hiddenCard.title` come back. + const ios: IosAchievement[] = redactedAchievements.map((a) => ({ id: a.id, key: a.id, title: t.t(a.titleKey), @@ -874,3 +898,66 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess(result); }); + +/** + * Hidden Easter-eggs ship to the client *only* as opaque cards while + * locked. Returning the full `AchievementProgress` shape would let a + * snooper read the trigger semantics from `metric`, `titleKey`, etc. + * This helper projects locked+hidden entries down to the safe shape + * the UI's opaque-placeholder branch needs. + * + * Unlocked hidden achievements pass through unchanged — the unlock IS + * the reveal moment, so the toast and the unlocked card show the real + * strings. + */ +function redactIfHiddenLocked( + achievement: AchievementProgress, +): AchievementProgress { + if (!achievement.isHidden || achievement.unlocked) return achievement; + return { + id: achievement.id, + metric: "totalTakenIntakes", + category: "hidden", + titleKey: "achievements.hiddenCard.title", + descriptionKey: "achievements.hiddenCard.description", + icon: "HelpCircle", + format: "count", + target: 0, + current: 0, + points: achievement.points, + unlocked: false, + progressPercent: 0, + completedAt: null, + isHidden: true, + }; +} + +/** + * Drop hidden-only metric counters from the response `metrics` block + * unless any hidden achievement they back is unlocked. The DOM never + * reads these counters today — the only consumer is the iOS client's + * progress bar — so removing the hidden ones from the wire is a pure + * spoiler shield with zero UI impact. + */ +function redactHiddenMetrics( + metrics: AchievementMetrics, + achievements: AchievementProgress[], +): Partial { + const HIDDEN_METRIC_KEYS = [ + "nightOwlCount", + "earlyBirdCount", + "leapDayCount", + "doctorPdfCount", + "localeFlipCount", + ] as const; + const safe: Partial = { ...metrics }; + for (const key of HIDDEN_METRIC_KEYS) { + const backing = achievements.find( + (a) => a.isHidden && a.metric === key && a.unlocked, + ); + if (!backing) { + delete safe[key]; + } + } + return safe; +}