mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(reconcile): r3-ios audit findings
This commit is contained in:
commit
ba6cb98148
6 files changed
+135
-34
No files matched your search
+26
-13
@@ -1918,6 +1918,14 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SleepNightResponse"
|
||||
"401": *a1
|
||||
"403": &a5
|
||||
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:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/sleep/rhythm:
|
||||
@@ -1939,6 +1947,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SleepRhythmResponse"
|
||||
"401": *a1
|
||||
"403": *a5
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/workouts:
|
||||
@@ -5154,7 +5163,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodCustomTagPatchEnvelope"
|
||||
"401": *a1
|
||||
"404": &a5
|
||||
"404": &a6
|
||||
description: Not found / not owned by the caller.
|
||||
content:
|
||||
application/json:
|
||||
@@ -5189,7 +5198,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodCustomTagDeleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a5
|
||||
"404": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags/{key}/hidden:
|
||||
@@ -5230,7 +5239,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"404": *a5
|
||||
"404": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags/groups:
|
||||
@@ -5305,7 +5314,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodTagGroupPatchEnvelope"
|
||||
"401": *a1
|
||||
"404": *a5
|
||||
"404": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -5336,7 +5345,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodTagGroupDeleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a5
|
||||
"404": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags/layout:
|
||||
@@ -5589,7 +5598,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderEnvelope"
|
||||
"401": *a1
|
||||
"404": &a6
|
||||
"404": &a7
|
||||
description: Measurement reminder not found / not owned.
|
||||
content:
|
||||
application/json:
|
||||
@@ -5623,7 +5632,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderPatchEnvelope"
|
||||
"401": *a1
|
||||
"404": *a6
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -5645,7 +5654,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderDeleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a6
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurement-reminders/{id}/satisfy:
|
||||
@@ -5669,7 +5678,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope"
|
||||
"401": *a1
|
||||
"404": *a6
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/labs:
|
||||
@@ -5816,7 +5825,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetLabResultResponse"
|
||||
"401": *a1
|
||||
"404": &a7
|
||||
"404": &a8
|
||||
description: Lab result not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -5890,7 +5899,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateLabResultResponse"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"404": *a8
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -5912,7 +5921,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteLabResultResponse"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"404": *a8
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
@@ -13751,6 +13760,8 @@ components:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: German KVNR, decrypted for the form prefill.
|
||||
modules:
|
||||
$ref: "#/components/schemas/ModuleMap"
|
||||
required:
|
||||
- username
|
||||
- email
|
||||
@@ -13765,9 +13776,11 @@ components:
|
||||
- insurerName
|
||||
- insurerIkNumber
|
||||
- insuranceNumber
|
||||
- modules
|
||||
additionalProperties: false
|
||||
description: Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6)
|
||||
is plaintext at rest.
|
||||
is plaintext at rest. The `modules` map mirrors the /api/auth/me projection so the alias carries the same module
|
||||
flags.
|
||||
PatchProfileResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user