mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Refuse what these four routes used to absorb in silence
Four surfaces answered a bad request by quietly substituting a good one. Each fix comes with the test that catches it, and each test was confirmed red against the old behaviour before the fix went back in. `PATCH /api/user/ai-provider` inspected its body key by key with `typeof` guards, so a numeric `model` or a boolean `baseUrl` failed every guard, produced no entry in the update, and the write went ahead without it — a 200 that says "saved" for a field that was discarded. A body of nothing but such keys came back as "No valid fields" naming none of them. The real schema now lives in `src/lib/validations/ai-provider.ts`, the route runs one `safeParse`, and a mismatch is the multi-issue 422 every sibling answers with. The accepted field set is unchanged, and the object is deliberately not strict: an unknown key has always been ignored here and still is. The one thing the schema has to carry that no other does is that this body holds plaintext credentials, so no rule may interpolate the value it rejected — noted where the next `.refine()` would go. `?slice=` on `/api/analytics` was compared against one literal and fell through to the DEFAULT body on anything else, so `?slice=summary` answered 200 after running the heaviest chain on the surface. Invisible and expensive at once. Closed enum, 422 on anything outside it. `?metricType=` and `?limit=` on `/api/personal-records` were parsed defensively, which read as robustness and behaved as silence — and in one direction it was worse than silence: dropping an unrecognised `metricType` WIDENS the read to every metric, so a typo returned more of the record than was asked for. Both refuse now, and the 500 ceiling refuses rather than clamps, because serving a different number of rows than were requested is the same quiet substitution. No caller in the tree sends a value the new rules reject. `POST /api/ingest/medication` bucketed on the client address, so a household running a chat bridge, a button and a home-automation rule — the normal deployment for this route — put all three in one 60/min cell and let the chattiest starve the rest. It already resolves a per-caller identity two lines further down and threw it away; the bucket is that token now. A request that resolves no token keeps the per-IP bucket it always had, through the shared anonymous helper, so a flood is still capped and a broken proxy chain collapses into the tight global cell rather than a shared "unknown" one. Its 429 goes through `apiError` and carries `meta` like every other refusal. Two older tests pinned the defects by name — "drops an unknown metricType silently" and "falls back to default on garbage ?limit" — and one pinned a throw where the rest of the surface returns an envelope. All three are rewritten with the reason beside them. The generated YAML is deliberately not in this commit.
This commit is contained in:
1 parent
49da15feaa
commit
bb9bf0ba68
15 files changed
+682
-255
No files matched your search
@@ -450,6 +450,52 @@ describe("GET /api/analytics", () => {
|
||||
// slice critical path).
|
||||
});
|
||||
|
||||
/**
|
||||
* `?slice=` is a closed set, and the cost of it not being one was the point.
|
||||
*
|
||||
* The route compared the raw string against `"summaries"` and fell through to
|
||||
* the DEFAULT body on anything else, so `?slice=summary` answered 200 after
|
||||
* running the heaviest chain on the surface. A client could sit on that typo
|
||||
* indefinitely and see nothing but a slow, correct-looking response — the
|
||||
* failure mode was invisible and expensive at the same time.
|
||||
*/
|
||||
describe("GET /api/analytics — the slice parameter is a closed set", () => {
|
||||
const call = (url: string) =>
|
||||
(GET as unknown as (request: Request) => Promise<Response>)(
|
||||
new Request(url),
|
||||
);
|
||||
|
||||
it.each(["summary", "SUMMARIES", "default", ""])(
|
||||
"refuses ?slice=%s rather than serving the expensive default body",
|
||||
async (value) => {
|
||||
const res = await call(
|
||||
`http://localhost/api/analytics?slice=${encodeURIComponent(value)}`,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
meta?: { errorCode?: string };
|
||||
details?: { issues?: Array<{ path: string }> };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("analytics.invalid_query");
|
||||
expect(body.details?.issues?.map((i) => i.path)).toContain("slice");
|
||||
},
|
||||
);
|
||||
|
||||
it("still serves the default body when the parameter is absent", async () => {
|
||||
const res = await call("http://localhost/api/analytics");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("still serves the slim body for the one legal value", async () => {
|
||||
const res = await call("http://localhost/api/analytics?slice=summaries");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The slim slice is recognisable by what it does NOT carry.
|
||||
expect(body.data).not.toHaveProperty("healthScore");
|
||||
expect(body.data).toHaveProperty("summaries");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { analyticsQuerySchema } from "@/lib/validations/analytics";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
@@ -46,13 +47,17 @@ export const dynamic = "force-dynamic";
|
||||
* tests instantiate `new Request("http://localhost/api/analytics")`
|
||||
* and cast through). Falls back to the raw `URL(request.url)` parse
|
||||
* so the slim slice branch is reachable from both call shapes.
|
||||
*
|
||||
* Returns the object the query schema parses: an absent parameter is an
|
||||
* absent KEY, not an empty string, so `slice` stays optional rather than
|
||||
* failing the enum on every unparameterised call.
|
||||
*/
|
||||
function readSliceParam(request: Request | undefined): string | null {
|
||||
if (!request) return null;
|
||||
function readQueryParams(request: Request | undefined): Record<string, string> {
|
||||
if (!request) return {};
|
||||
try {
|
||||
return new URL(request.url).searchParams.get("slice");
|
||||
return Object.fromEntries(new URL(request.url).searchParams);
|
||||
} catch {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +65,21 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// A `slice` outside the closed set is refused rather than served the
|
||||
// default body. Falling through was the expensive failure: `?slice=summary`
|
||||
// ran the full 30-query chain and answered 200, so a client could sit on a
|
||||
// typo indefinitely while paying for the heaviest read on the surface.
|
||||
const parsedQuery = analyticsQuerySchema.safeParse(readQueryParams(request));
|
||||
if (!parsedQuery.success) {
|
||||
annotate({
|
||||
action: { name: "analytics.get" },
|
||||
meta: { outcome: "invalid_query" },
|
||||
});
|
||||
return returnAllZodIssues(parsedQuery.error, 422, {
|
||||
errorCode: "analytics.invalid_query",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
|
||||
// The default path runs 30+ chunked findMany reads to feed
|
||||
// `summarize()` per type; under `?slice=summaries` the route
|
||||
@@ -72,7 +92,7 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// behind a hover-prefetch from the Coach drawer + the correlation
|
||||
// tile's `<InView>` boundary. Consumers that need only the headline
|
||||
// tile values land on this branch and skip the heavy chain.
|
||||
if (readSliceParam(request) === "summaries") {
|
||||
if (parsedQuery.data.slice === "summaries") {
|
||||
// v1.4.34 IW-G — read-through the analytics cache keyed on
|
||||
// (userId, slice). The slim slice is the dashboard tile strip's hot
|
||||
// path; multiple dashboard mounts inside a 60-second TTL all hit a
|
||||
|
||||
Reference in new issue
Block a user