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:
Marc-André Bombeck committed 2026-08-22 12:39:43 +02:00
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());
});
});
+6 -2
View File
@@ -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.