mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -148,7 +148,15 @@ describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
// The three cases below pin the capped `safeJson` read. Before it, the
|
||||
// handler called `request.json()` bare: malformed JSON came back 422 rather
|
||||
// than the 400 every sibling scalar answers, a wrong content type was parsed
|
||||
// rather than refused, and there was NO body-size limit — this was the one
|
||||
// endpoint in the family that would materialise whatever it was handed. The
|
||||
// malformed-JSON assertion used to say 422 and was changed deliberately: it
|
||||
// pinned the inconsistency rather than a contract, and the only caller
|
||||
// branches on `res.ok` alone.
|
||||
it("rejects malformed JSON with 400, like its sibling scalars", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
@@ -156,7 +164,36 @@ describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a body over the 1 KB cap with 413", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Valid JSON, valid enum value, and far too big: the cap has to be what
|
||||
// rejects it, not the schema.
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
unitPreference: "imperial",
|
||||
padding: "x".repeat(2048),
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(413);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a non-JSON content type with 415", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/unit-preference", {
|
||||
method: "PATCH",
|
||||
body: "unitPreference=imperial",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(415);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,13 +15,22 @@
|
||||
* Mirrors the `disable-coach` per-user-scalar pattern: 60/min rate
|
||||
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment).
|
||||
*
|
||||
* The body goes through `safeJson` with a 1 KB cap, like the
|
||||
* `documents-auto-ai-read` scalar beside it. It used to read a bare
|
||||
* `request.json()`, which applied no size limit at all — the one endpoint in
|
||||
* this family that would materialise whatever it was sent — and answered a
|
||||
* malformed body with 422 where every sibling answers 400. Both were
|
||||
* accidents of the original copy, not a contract: the only caller checks
|
||||
* `res.ok` and never the status.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -73,12 +82,12 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "unit-preference.body.invalid_json");
|
||||
}
|
||||
// The body is a single enum value — bound the parse so a malformed or
|
||||
// oversized payload is rejected before it is materialised.
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = unitPreferencePatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* `PATCH /api/auth/passkeys/{id}` — the rename's refusal has to name the field.
|
||||
*
|
||||
* The handler used to answer `apiError("Invalid request", 422)` and throw the
|
||||
* Zod issues away, which is the one thing every sibling body-parsing route in
|
||||
* this tree does not do. A person renaming a passkey to an empty string and a
|
||||
* person pasting 200 characters got the same three words, so the form had
|
||||
* nothing to put beside the field and no way to say which rule was broken.
|
||||
*
|
||||
* These cases pin the multi-issue envelope by its CONTENT — the issue list has
|
||||
* to be present and has to point at `name` — rather than by status alone. A
|
||||
* status-only assertion would have passed against the flat error it replaced.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
passkey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
count: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" };
|
||||
|
||||
const ROW = {
|
||||
id: "pk-1",
|
||||
name: "Phone",
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
|
||||
type IssueEnvelope = {
|
||||
error: string;
|
||||
details?: { issues?: Array<{ path?: unknown; message?: string }> };
|
||||
};
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/passkeys/pk-1", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const params = Promise.resolve({ id: "pk-1" });
|
||||
|
||||
type Handler = (
|
||||
r: Request,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/passkeys/{id} — the rename refusal", () => {
|
||||
it("returns the multi-issue envelope for an empty name", async () => {
|
||||
const res = await (PATCH as unknown as Handler)(mkPatch({ name: "" }), {
|
||||
params,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as IssueEnvelope;
|
||||
// The flat refusal this replaces said "Invalid request" and carried no
|
||||
// `details` at all, so both halves matter.
|
||||
expect(env.error).toBe("Validation failed");
|
||||
expect(env.details?.issues?.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(env.details?.issues)).toContain("name");
|
||||
expect(prisma.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the multi-issue envelope for a name over 64 characters", async () => {
|
||||
const res = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "x".repeat(65) }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as IssueEnvelope;
|
||||
expect(env.details?.issues?.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(env.details?.issues)).toContain("name");
|
||||
expect(prisma.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes the two refusals from each other", async () => {
|
||||
// The point of the change: a client can tell WHICH rule was broken. Under
|
||||
// the flat error both bodies were byte-identical.
|
||||
const tooShort = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "" }),
|
||||
{ params },
|
||||
);
|
||||
const tooLong = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "x".repeat(65) }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(JSON.stringify(await tooShort.json())).not.toBe(
|
||||
JSON.stringify(await tooLong.json()),
|
||||
);
|
||||
});
|
||||
|
||||
it("still renames on a valid name, trimming it first", async () => {
|
||||
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
|
||||
userId: USER.id,
|
||||
} as never);
|
||||
vi.mocked(prisma.passkey.update).mockResolvedValue({
|
||||
...ROW,
|
||||
name: "Work laptop",
|
||||
} as never);
|
||||
|
||||
const res = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: " Work laptop " }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.passkey.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { name: "Work laptop" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leak another account's passkey through the rename", async () => {
|
||||
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
|
||||
userId: "someone-else",
|
||||
} as never);
|
||||
|
||||
const res = await (PATCH as unknown as Handler)(
|
||||
mkPatch({ name: "Mine now" }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.passkey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -26,7 +27,12 @@ export const PATCH = apiHandler(
|
||||
|
||||
const parsed = passkeyRenameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
// Multi-issue, like every sibling that parses a body. The flat
|
||||
// `apiError("Invalid request", 422)` this replaces discarded the issues,
|
||||
// so a person renaming a passkey could not tell an empty name from one
|
||||
// over the 64-character limit — the form had nothing to put beside the
|
||||
// field.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
|
||||
@@ -8,6 +8,11 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Revoke an API token.
|
||||
*
|
||||
* Still gated on the instance-wide API switch, where the sibling LIST is not.
|
||||
* The reasoning for the split lives in `../route.ts`: the list answers which
|
||||
* credentials exist on the caller's own account and is never the unsafe half,
|
||||
* while this one mutates the token surface the operator has switched off.
|
||||
*/
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* `/api/tokens` — the instance-wide API switch must not hide live credentials.
|
||||
*
|
||||
* `AppSettings.apiGlobal` gates the surfaces a token is FOR — external
|
||||
* medication ingest, the MCP bridge — and not a token's ability to
|
||||
* authenticate: nothing on the `requireAuth` path consults it, which the last
|
||||
* case here asserts directly rather than assuming. So a token minted before an
|
||||
* operator flipped the switch stays a live credential while it is off, and the
|
||||
* LIST used to answer 403 — hiding exactly the credentials their owner most
|
||||
* needed to see, including the wildcard access token their own phone signs in
|
||||
* with.
|
||||
*
|
||||
* The read is ungated now. The revoke is not, and that asymmetry is pinned
|
||||
* below rather than left to drift: it is a decision about what the switch
|
||||
* means, and a change to it should be a visible diff here.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() }));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { DELETE } from "../[id]/route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
const USER = { id: "user-1", username: "u" };
|
||||
|
||||
const ROW = {
|
||||
id: "tok-1",
|
||||
name: "iPhone",
|
||||
permissions: ["*"],
|
||||
lastUsedAt: new Date("2026-08-01T10:00:00Z"),
|
||||
expiresAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
revoked: false,
|
||||
};
|
||||
|
||||
type ListEnvelope = {
|
||||
data: Array<{ id: string }> | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const params = Promise.resolve({ id: "tok-1" });
|
||||
|
||||
type DeleteHandler = (
|
||||
r: Request,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(prisma.apiToken.findMany).mockResolvedValue([ROW] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/tokens", () => {
|
||||
it("lists the caller's tokens while the operator has the API switched off", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
|
||||
const res = await (GET as unknown as () => Promise<Response>)();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as ListEnvelope;
|
||||
expect(env.error).toBeNull();
|
||||
expect(env.data?.[0]?.id).toBe("tok-1");
|
||||
expect(prisma.apiToken.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: USER.id } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not consult the switch at all", async () => {
|
||||
// Stronger than the status assertion above and the reason it is separate:
|
||||
// a future re-gating that happened to answer 200 anyway would still be a
|
||||
// read whose behaviour depends on an operator toggle it has no business
|
||||
// reading.
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
|
||||
await (GET as unknown as () => Promise<Response>)();
|
||||
|
||||
expect(isApiGloballyEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still scopes the list to the caller", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
|
||||
await (GET as unknown as () => Promise<Response>)();
|
||||
|
||||
const args = vi.mocked(prisma.apiToken.findMany).mock.calls[0][0];
|
||||
expect(args?.where).toEqual({ userId: USER.id });
|
||||
// No `revoked: false` filter — revoked rows stay in the list, which is
|
||||
// what the published contract says and what the UI relies on.
|
||||
expect(JSON.stringify(args?.where)).not.toContain("revoked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/tokens/{id}", () => {
|
||||
it("keeps refusing while the operator has the API switched off", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
|
||||
const res = await (DELETE as unknown as DeleteHandler)(
|
||||
new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.apiToken.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes when the switch is on", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
userId: USER.id,
|
||||
} as never);
|
||||
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as unknown as DeleteHandler)(
|
||||
new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.apiToken.update).toHaveBeenCalledWith({
|
||||
where: { id: "tok-1" },
|
||||
data: { revoked: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reach another account's token", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
...ROW,
|
||||
userId: "someone-else",
|
||||
} as never);
|
||||
|
||||
const res = await (DELETE as unknown as DeleteHandler)(
|
||||
new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }),
|
||||
{ params },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.apiToken.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the switch and the authentication path", () => {
|
||||
it("is not consulted by requireAuth, which is why the read must not gate on it", async () => {
|
||||
// The premise the fix rests on, asserted rather than assumed: if the auth
|
||||
// kit ever started refusing Bearer callers while `apiGlobal` is off, then
|
||||
// a token would NOT be live during the outage and hiding the list would
|
||||
// stop being wrong. This reads the source because the behaviour is an
|
||||
// absence, and an absence has no call to spy on.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/lib/api-handler.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toContain("isApiGloballyEnabled");
|
||||
expect(source).not.toContain("apiGlobal");
|
||||
});
|
||||
});
|
||||
@@ -11,21 +11,31 @@
|
||||
* (`/api/medications/[id]/api-endpoint`), which issues both grants.
|
||||
*
|
||||
* Listing and revoking stay so existing tokens remain visible and revocable.
|
||||
*
|
||||
* The list is NOT gated on the instance-wide API switch, and the revoke is.
|
||||
* The asymmetry is deliberate, and rests on what the switch actually does:
|
||||
* `AppSettings.apiGlobal` gates the surfaces a token is FOR — external
|
||||
* medication ingest, the MCP bridge — and not the token's ability to
|
||||
* authenticate. Nothing on the `requireAuth` path consults it. So a token
|
||||
* minted before an operator flipped the switch is still a live credential
|
||||
* while it is off, and refusing the list hid exactly the credentials their
|
||||
* owner most needed to see. Answering "which credentials exist on your own
|
||||
* account" is never the unsafe half of this surface: it discloses no token
|
||||
* material, and it is how a person finds the row to kill.
|
||||
*
|
||||
* The revoke keeps the gate as the surface stands. Whether the switch should
|
||||
* also stop an owner killing a live token is a question about what the switch
|
||||
* means, not a property of the read, so it is left where it was.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "tokens.list" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1316,7 +1316,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Rename a registered passkey",
|
||||
description:
|
||||
"Relabels one primary sign-in credential, scoped to the authenticated user. Returns the full passkey row, so the client can replace its list entry rather than re-read.\n\n" +
|
||||
'Unlike the rest of this surface the 422 carries a single flat message rather than the multi-issue envelope `returnAllZodIssues` produces — the handler answers `apiError("Invalid request", 422)` and discards the Zod issues, so a client cannot tell an empty name from an over-long one.',
|
||||
"The name is trimmed before validation and must be 1 to 64 characters once trimmed. A rejected name earns the standard multi-issue 422, so a form can put the reason beside the field rather than showing a bare “invalid request”.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -1376,7 +1376,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "List the caller's API tokens",
|
||||
description:
|
||||
"Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" +
|
||||
'Two things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. And there is no mint on this path any more — the generic POST that issued `["medication:ingest"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The working credential comes from the per-medication API-endpoint toggle.',
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no mint on this path any more — the generic POST that issued `[\"medication:ingest\"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else; the working credential comes from the per-medication API-endpoint toggle. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's tokens, newest first.",
|
||||
@@ -1389,11 +1389,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Checked after authentication, carries no `meta.errorCode`, and applies to the read as well as the revoke — a user cannot inspect their own tokens while the switch is off.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
@@ -1420,7 +1415,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked.",
|
||||
"The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is off, so its owner can still see it here even though this call will not kill it.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
|
||||
@@ -1223,7 +1223,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Set the metric/imperial display preference",
|
||||
description:
|
||||
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
|
||||
"The only endpoint in this group that reads its body with a bare `request.json()` rather than the capped `safeJson` helper, so it applies no body-size limit and answers a malformed body with 422 where its siblings answer 400. Both are the code as it stands.",
|
||||
"The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group answer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1242,6 +1242,10 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"413": {
|
||||
description: "Request body exceeds 1024 bytes. Nothing was written.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user