Files
HealthLog/tests/integration/routes/kit-contract.test.ts
T
Marc-André Bombeck c272324c1e fix(api): declare the auth gate's 403 on every operation behind it
requireAuth answers 403 auth.scope.insufficient to a narrow Bearer token
on a route that names no scope it holds, and requireAdmin answers 403
auth.admin.required to a session that is not an administrator's. Both
run before any route body, and the contract only said so in the prose
of the shared 401, so a client generated from it had no typed branch
for either. The route kit accepted the scope refusal on any operation
that declared a 401 for the same reason.

The 403 is added to the assembled path table rather than to
stdResponses. That set is spread into most operations, about three
hundred of them carry a 403 of their own, and most spread the set after
it, so a shared 403 there would have overwritten every module-off and
sharing refusal in the document. withScopeRefusal adds the shared
response where an operation has none and prefixes an operation's own
403 with the gate's code, keeping the sharing sentence last. Operations
that take no credential, and the three surfaces that authenticate by a
credential of their own, are left as they were.

The kit no longer tolerates the refusal on an operation's behalf, and a
guard holds every operation behind the gate to declaring it.
2026-10-22 07:07:19 +02:00

114 lines
3.4 KiB
TypeScript

/**
* The route kit's contract check, proven able to fail.
*
* Every kit test leans on `callRoute` refusing a response the published
* contract does not describe. A check that passes whatever it is handed would
* turn every one of them into a weaker test than it claims to be, so each
* refusal is exercised here against a handler that answers exactly the wrong
* thing, wrapped in the real `apiHandler`.
*/
import { NextResponse } from "next/server";
import { describe, expect, it } from "vitest";
import { apiHandler } from "@/lib/api-handler";
import { ContractViolation, anonymous, callRoute } from "../../kit/call-route";
const token = {
id: "tok-1",
name: "phone",
permissions: ["*"],
lastUsedAt: null,
expiresAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
revoked: false,
};
function answering(status: number, body: unknown) {
return apiHandler(async () => NextResponse.json(body, { status }));
}
describe("callRoute's contract check", () => {
it("accepts a body the registered schema describes", async () => {
const res = await callRoute(
answering(200, { data: [token], error: null }),
{
method: "GET",
route: "/api/tokens",
auth: anonymous,
},
);
expect(res.status).toBe(200);
});
it("refuses a key the registered schema does not declare", async () => {
await expect(
callRoute(
answering(200, {
data: [{ ...token, tokenHash: "abc" }],
error: null,
}),
{ method: "GET", route: "/api/tokens", auth: anonymous },
),
).rejects.toThrow(/does not declare: data\[0\]\.tokenHash/);
});
it("refuses a body of the wrong shape", async () => {
await expect(
callRoute(
answering(200, { data: [{ ...token, revoked: "no" }], error: null }),
{
method: "GET",
route: "/api/tokens",
auth: anonymous,
},
),
).rejects.toBeInstanceOf(ContractViolation);
});
it("refuses a status the operation does not declare", async () => {
await expect(
callRoute(answering(409, { data: null, error: "conflict" }), {
method: "GET",
route: "/api/tokens",
auth: anonymous,
}),
).rejects.toThrow(/answered 409, which the contract does not declare/);
});
it("refuses the scope gate's 403 on an operation that declares none", async () => {
// `/api/version` takes no credential, so no 403 is declared on it; the
// generic refusal is accepted only where the contract says so.
await expect(
callRoute(
answering(403, {
data: null,
error: "Insufficient scope",
meta: { errorCode: "auth.scope.insufficient" },
}),
{ method: "GET", route: "/api/version", auth: anonymous },
),
).rejects.toThrow(/answered 403/);
});
it("refuses a route the registry does not know", async () => {
await expect(
callRoute(answering(200, { data: null, error: null }), {
method: "GET",
route: "/api/not-a-route",
auth: anonymous,
}),
).rejects.toThrow(/is not in the OpenAPI registry/);
});
it("skips the check only when told why", async () => {
const res = await callRoute(answering(200, { anything: true }), {
method: "GET",
route: "/api/not-a-route",
auth: anonymous,
contract: { skip: "exercising the escape hatch itself" },
});
expect(res.body).toEqual({ anything: true });
});
});