mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(achievements): redact hidden-locked entries from API response
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
3d585571d8
commit
545f44c074
2 files changed
+297
-4
No files matched your search
@@ -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<string, unknown>;
|
||||
};
|
||||
};
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<AchievementMetrics> {
|
||||
const HIDDEN_METRIC_KEYS = [
|
||||
"nightOwlCount",
|
||||
"earlyBirdCount",
|
||||
"leapDayCount",
|
||||
"doctorPdfCount",
|
||||
"localeFlipCount",
|
||||
] as const;
|
||||
const safe: Partial<AchievementMetrics> = { ...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;
|
||||
}
|
||||
Reference in new issue
Block a user