mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
114 lines
3.4 KiB
TypeScript
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 });
|
|
});
|
|
});
|