Files
HealthLog/src/lib/http/__tests__/read-capped.test.ts
T
Marc-André Bombeck 22eb9cba0d fix(mcp): close OAuth 2.1 resource-server gaps before the remote surface ships
Harden the remote MCP endpoint and the in-repo OAuth bridge against the
adversarial review of the milestone branch. The two ship-blockers made the
"read-only, revocable" promise false; the rest tightened the surface.

Audience binding at the resource server: a token whose only grant is the new
`health:read` scope is the MCP read credential. The REST edge now accepts it on
safe (read) methods and refuses it on writes/deletes with 403, so a connector
token can read over `/mcp` and GET routes but can never POST/DELETE the record
despite a "read-only" consent. The `/mcp` resolver is unaffected.

Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a
persistent connection row. Revoking a connection stops every future refresh and
kills its access tokens; a replayed (already-rotated) refresh is treated as
theft and revokes the whole connection. Connections surface in settings as the
unit a user can disconnect. The old access token is revoked on rotation.

Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is
configured and mark the discovery documents non-cacheable; honour the global API
kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and
registration body reads while streaming instead of after; show the verified
client origin on consent; validate CIMD redirect-URI schemes; assert same-origin
on the consent POST; and add a daily prune for expired connector tokens.

New migration 0195 adds the connection anchor and links access tokens to it.
2026-06-27 15:09:11 +02:00

69 lines
2.2 KiB
TypeScript

/**
* Bounded body reader (M3) — reject oversized bodies WITHOUT buffering them.
*/
import { describe, it, expect } from "vitest";
import { readBodyCapped } from "../read-capped";
function streamOf(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) controller.enqueue(chunks[i++]);
else controller.close();
},
});
}
const enc = (s: string) => new TextEncoder().encode(s);
describe("readBodyCapped", () => {
it("returns the decoded text under the cap", async () => {
const res = await readBodyCapped(
{ headers: new Headers(), body: streamOf([enc("hello "), enc("world")]) },
1024,
);
expect(res).toEqual({ ok: true, text: "hello world" });
});
it("rejects up front on an oversized Content-Length without consuming the body", async () => {
const body = streamOf([enc("x")]);
let getReaderCalled = false;
const original = body.getReader.bind(body);
body.getReader = ((...args: unknown[]) => {
getReaderCalled = true;
// @ts-expect-error — passthrough spy
return original(...args);
}) as typeof body.getReader;
const res = await readBodyCapped(
{ headers: new Headers({ "content-length": "999999" }), body },
16,
);
expect(res).toEqual({ ok: false, reason: "too_large" });
expect(getReaderCalled).toBe(false);
});
it("aborts mid-stream the moment the running total exceeds the cap", async () => {
let chunksPulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(c) {
chunksPulled++;
// 10 bytes per chunk; cap is 16, so the 2nd chunk overflows.
c.enqueue(enc("0123456789"));
},
});
const res = await readBodyCapped({ headers: new Headers(), body }, 16);
expect(res).toEqual({ ok: false, reason: "too_large" });
// It must stop pulling once it overflows, not drain the (infinite) stream.
expect(chunksPulled).toBeLessThanOrEqual(2);
});
it("treats a null body as empty", async () => {
const res = await readBodyCapped(
{ headers: new Headers(), body: null },
16,
);
expect(res).toEqual({ ok: true, text: "" });
});
});