merge(reconcile): r3-ios audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:59:31 +02:00
commit ba6cb98148
6 files changed
+135 -34

No files matched your search

@@ -19,6 +19,18 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// v1.18.0 — the GET alias resolves the module map. Stub it so these
// tests assert the projection wiring without standing up the gate's
// own DB round-trips (operator availability + assistant flags + cycle).
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn().mockResolvedValue({
sleep: true,
glucose: true,
cycle: false,
coach: true,
}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -31,6 +43,14 @@ vi.mock("next/headers", () => ({
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveModuleMap } from "@/lib/modules/gate";
const MODULE_MAP = {
sleep: true,
glucose: true,
cycle: false,
coach: true,
} as const;
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -39,6 +59,7 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -108,6 +129,36 @@ describe("GET /api/user/profile", () => {
expect(body.data.insurerIkNumber).toBe("101234567");
expect(body.data.insurerName).toBe("Example Insurer");
});
// v1.18.0 — the alias the iOS app reads must carry the resolved module
// map, matching the /api/auth/me projection so the client gets module
// flags from the endpoint it actually fetches.
it("includes the resolved module map", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "testuser",
displayName: null,
email: null,
dateOfBirth: null,
gender: null,
heightCm: null,
locale: null,
timezone: "Europe/Berlin",
moodReminderEnabled: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumberEncrypted: null,
} as never);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
expect(resolveModuleMap).toHaveBeenCalledWith("user-1");
const body = (await res.json()) as {
data: { modules: Record<string, boolean> };
};
expect(body.data.modules).toEqual(MODULE_MAP);
});
});
describe("PATCH /api/user/profile", () => {
+34 -19
View File
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveModuleMap } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
@@ -25,25 +26,35 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "user.profile.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
username: true,
displayName: true,
email: true,
dateOfBirth: true,
gender: true,
heightCm: true,
locale: true,
timezone: true,
timeFormat: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
insurerIkNumber: true,
insuranceNumberEncrypted: true,
},
});
// Overlap the profile-row read with the module-map resolve (a separate
// memoised Postgres round-trip) — both feed the same response and have
// no ordering dependency.
const [dbUser, modules] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
username: true,
displayName: true,
email: true,
dateOfBirth: true,
gender: true,
heightCm: true,
locale: true,
timezone: true,
timeFormat: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
insurerIkNumber: true,
insuranceNumberEncrypted: true,
},
}),
// v1.18.0 — resolved module enable/disable map, identical to the
// projection `GET /api/auth/me` returns. The iOS app reads this alias
// endpoint, so it must carry the same server-authoritative flags; a
// `false` key means the module surface disappears end-to-end.
resolveModuleMap(user.id),
]);
// v1.7.0 — decrypt the KVNR fail-soft so a key-rotation gap never 500s
// the profile fetch.
@@ -71,6 +82,10 @@ export const GET = apiHandler(async () => {
insurerName: dbUser?.insurerName ?? null,
insurerIkNumber: dbUser?.insurerIkNumber ?? null,
insuranceNumber,
// v1.18.0 — module enable/disable map, matching the /api/auth/me
// projection exactly so the native client gets module flags from the
// endpoint it actually reads.
modules,
});
});
+3 -1
View File
@@ -14,7 +14,7 @@ import {
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { dataEnvelope, stdResponses } from "./shared";
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
const batchEntrySchema = z
.object({
@@ -600,6 +600,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
@@ -619,6 +620,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
+5 -1
View File
@@ -219,11 +219,15 @@ const profileResponse = z
.string()
.nullable()
.describe("German KVNR, decrypted for the form prefill."),
// v1.18.0 — resolved module enable/disable map, identical to the
// /api/auth/me projection. The native client reads this alias, so it
// must carry the same server-authoritative module flags.
modules: moduleMapResolved,
})
.meta({
id: "ProfileResponse",
description:
"Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest.",
"Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest. The `modules` map mirrors the /api/auth/me projection so the alias carries the same module flags.",
});
// The PATCH echo mirrors GET but reports KVNR presence as a boolean
+16
View File
@@ -124,3 +124,19 @@ export const consentRequiredResponse = {
content: { "application/json": { schema: errorEnvelope } },
},
};
// ── Module-disabled gate (v1.18.0) ───────────────────────────────────
// Every module-scoped route runs `requireModuleEnabled(userId, key)`,
// which returns a 403 when the account has the module turned off — even
// with a valid Bearer token. The envelope carries
// `meta.errorCode = "module.disabled"` and `meta.module` (the disabled
// module key) so the iOS retry classifier branches on it and the client
// can drop the whole surface. The errorEnvelope shape already declares
// `meta.errorCode`; `meta.module` is documented here in prose.
export const moduleDisabledResponse = {
"403": {
description:
'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.',
content: { "application/json": { schema: errorEnvelope } },
},
};