mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Stop the API switch hiding live tokens, and cap the unit-preference body
Four defects the registry work surfaced, each with a check that fails without
the fix.
`GET /api/tokens` no longer consults the instance-wide API switch. That switch
gates the surfaces a token is FOR — external ingest, the MCP bridge — and not a
token's ability to authenticate: nothing on the `requireAuth` path reads it, so
a token minted before an operator flipped it stays live while it is off, and
refusing the list hid exactly the credentials their owner most needed to see,
their own signed-in phone among them. The revoke keeps the gate; the asymmetry
is stated in both route comments and pinned by the test, because widening it is
a decision about what the switch means rather than a fix to the read.
`PATCH /api/auth/me/unit-preference` reads its body through `safeJson` with a
1 KB cap, like the scalar beside it. It used a bare `request.json()` and was the
one endpoint in the family with no body-size limit at all — the new test proves
it by sending 2 KB and watching the old handler answer 200. Malformed JSON now
answers 400 like every sibling instead of 422; the only caller checks `res.ok`
and never the status, so nothing depends on the old code. The existing
malformed-JSON assertion was changed deliberately: it pinned the inconsistency,
not a contract.
`PATCH /api/auth/passkeys/{id}` returns the multi-issue envelope. It answered a
flat "Invalid request", so an empty name and a 200-character name produced
byte-identical bodies and the form had nothing to show beside the field.
`GET /api/user/thresholds` has a header that describes the handler. It promised
`{ defaults, overrides, effective }` and has always returned
`{ effective, overrides }`. The guard compares the two, so the comment cannot
drift again in either direction.
The two published operations whose contracts moved are updated with them.
This commit is contained in:
1 parent
b6efc6cb5a
commit
63f791120a
11 files changed
+507
-28
No files matched your search
@@ -44,9 +44,11 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { GET, PUT } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAllEffectiveRanges } from "@/lib/analytics/effective-range";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -108,3 +110,73 @@ describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The docblock promised a key the handler has never returned.
|
||||
*
|
||||
* For as long as this route has existed its header said GET answers
|
||||
* `{ defaults, overrides, effective }`. It answers `{ effective, overrides }`;
|
||||
* the per-metric defaults live inside `effective[metric].default`. Anyone
|
||||
* reading the file to find out what to decode was told to expect a top-level
|
||||
* key that is not there.
|
||||
*
|
||||
* Asserting the shape alone would not have caught it — the code was always
|
||||
* right. So this compares the two: the keys the response actually carries
|
||||
* against the keys the docblock advertises. A comment that drifts from the
|
||||
* handler fails here, in either direction.
|
||||
*/
|
||||
describe("GET /api/user/thresholds — the docblock describes the response", () => {
|
||||
const RESPONSE_KEYS = ["effective", "overrides"];
|
||||
|
||||
it("returns exactly the documented top-level keys", async () => {
|
||||
// `beforeEach` calls `vi.resetAllMocks()`, which clears the module-level
|
||||
// `mockReturnValue({})` — without re-arming it the resolver returns
|
||||
// undefined and `effective` vanishes from the JSON entirely.
|
||||
vi.mocked(getAllEffectiveRanges).mockReturnValue({
|
||||
WEIGHT: {
|
||||
range: { greenMin: 60, greenMax: 80, orangeMin: 57, orangeMax: 83 },
|
||||
isOverride: false,
|
||||
default: { greenMin: 60, greenMax: 80, orangeMin: 57, orangeMax: 83 },
|
||||
bounds: { min: 30, max: 300, unit: "kg" },
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
thresholdsJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(Object.keys(body.data).sort()).toEqual([...RESPONSE_KEYS].sort());
|
||||
expect(body.data).not.toHaveProperty("defaults");
|
||||
});
|
||||
|
||||
it("names those same keys in the route's own header comment", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/app/api/user/thresholds/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const docblock = source.slice(0, source.indexOf("*/"));
|
||||
|
||||
// The promise the header makes, as a brace list: `{ effective, overrides }`.
|
||||
const promised = docblock.match(/GET returns `\{([^}]+)\}`/);
|
||||
expect(
|
||||
promised,
|
||||
"the header no longer states what GET returns",
|
||||
).not.toBeNull();
|
||||
|
||||
const promisedKeys = (promised?.[1] ?? "")
|
||||
.split(",")
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
expect(promisedKeys).toEqual([...RESPONSE_KEYS].sort());
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* GET / PUT / DELETE per-user threshold overrides.
|
||||
*
|
||||
* GET returns `{ defaults, overrides, effective }` so the settings UI can
|
||||
* show a side-by-side comparison.
|
||||
* GET returns `{ effective, overrides }`. The side-by-side comparison the
|
||||
* settings UI draws comes out of `effective`, not out of a sibling key: each
|
||||
* entry carries `range` (the band in force), `isOverride`, `default` (the
|
||||
* unmodified computed band) and `bounds`. There is no top-level `defaults`
|
||||
* key and there never was — this comment promised one for long enough that
|
||||
* it is worth stating the absence rather than just deleting the word.
|
||||
* PUT accepts a partial map — only the provided metrics are updated, others
|
||||
* keep their existing override or remain on the default.
|
||||
* DELETE with ?metric=... resets one metric to default.
|
||||
|
||||
Reference in new issue
Block a user