patchPreferencesRequest forwarded inbound headers (including
content-length from the browser PATCH body) but set a DIFFERENT body
({ preferences }). The Request constructor does NOT recompute
content-length when an explicit body is provided alongside forwarded
headers, so user-service waited for bytes that never arrived and closed
the socket (~2.8s 'other side closed') → uncaught → 500.
Delete content-length and transfer-encoding before constructing the
synthetic request so undici recomputes them from the actual body. Apply
the same defensive deletes in fetchUserProfile.
Extract patchPreferencesRequest into the DB-free
src/services/user-profile.ts module (alongside fetchUserProfile) so the
regression test imports the REAL function and asserts content-length /
transfer-encoding / host / cookie are stripped and the body is the
synthetic { preferences } blob. tsc clean; existing onboarding tests
pass.
166 lines
7.6 KiB
TypeScript
166 lines
7.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
|
|
|
|
/**
|
|
* Regression test for the PATCH /onboarding 500.
|
|
*
|
|
* Root cause: the PATCH handler parsed its JSON body via `c.req.json()`
|
|
* (consuming `c.req.raw` / setting bodyUsed), then called fetchUserProfile
|
|
* with the same Request. fetchUserProfile replayed the inbound method+body to
|
|
* user-service /me, so fetchUserService did `await req.arrayBuffer()` on an
|
|
* already-consumed body → `TypeError: Body is unusable` → uncaught → global
|
|
* onError → {"error":"internal"}, 500. GET /onboarding worked only because it
|
|
* never reads a body, so fetchUserService skipped arrayBuffer.
|
|
*
|
|
* This test reproduces the exact precondition — consume the body first — and
|
|
* asserts the fix: fetchUserProfile issues an explicit GET that never touches
|
|
* the inbound Request's body. It would have thrown before the fix and passes
|
|
* after.
|
|
*/
|
|
|
|
// Capture the outgoing fetch call.
|
|
type FetchCall = { method: string; url: string; bodyTouched: boolean };
|
|
|
|
async function runFetchUserProfileAfterBodyConsumed(): Promise<{
|
|
profile: Record<string, unknown>;
|
|
call: FetchCall;
|
|
}> {
|
|
const originalFetch = globalThis.fetch;
|
|
let captured: FetchCall | null = null;
|
|
|
|
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json", authorization: "Bearer test" },
|
|
body: JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 }),
|
|
});
|
|
|
|
// Simulate what the PATCH handler does: parse the JSON body. This marks
|
|
// req.bodyUsed = true — the precondition that triggered the 500.
|
|
await req.json();
|
|
|
|
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
|
const target = input instanceof URL ? input : new URL(input instanceof Request ? input.url : String(input));
|
|
// Detect if the caller tried to re-read the original (consumed) Request body.
|
|
let bodyTouched = false;
|
|
if (input instanceof Request) {
|
|
try {
|
|
await input.clone().arrayBuffer();
|
|
bodyTouched = input.bodyUsed;
|
|
} catch {
|
|
bodyTouched = true; // Body is unusable — the original bug.
|
|
}
|
|
}
|
|
captured = { method: init?.method ?? input.method ?? "GET", url: target.href, bodyTouched };
|
|
return new Response(JSON.stringify({ preferences: { onboarding: { access_choice: "trial" } } }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}) as typeof globalThis.fetch;
|
|
|
|
try {
|
|
const profile = await fetchUserProfile(req);
|
|
assert(captured, "fetch was never called");
|
|
return { profile, call: captured };
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
}
|
|
|
|
// ── 1. no throw when inbound body was already consumed ──────────────────────
|
|
{
|
|
const { call } = await runFetchUserProfileAfterBodyConsumed();
|
|
assert.equal(call.bodyTouched, false, "fetchUserProfile must not re-read the inbound Request body");
|
|
}
|
|
|
|
// ── 2. outgoing method is GET (a read, never PATCH) ─────────────────────────
|
|
{
|
|
const { call } = await runFetchUserProfileAfterBodyConsumed();
|
|
assert.equal(call.method, "GET", "fetchUserProfile must issue GET /me regardless of inbound method");
|
|
}
|
|
|
|
// ── 3. target is /api/v1/users/me and profile parses ───────────────────────
|
|
{
|
|
const { profile, call } = await runFetchUserProfileAfterBodyConsumed();
|
|
assert.ok(call.url.endsWith("/api/v1/users/me"), `expected /me URL, got ${call.url}`);
|
|
assert.deepEqual(profile, { preferences: { onboarding: { access_choice: "trial" } } });
|
|
}
|
|
|
|
// ── 4. content-type header is stripped (GET has no body) ────────────────────
|
|
{
|
|
const originalFetch = globalThis.fetch;
|
|
let capturedHeaders: Headers | null = null;
|
|
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
|
capturedHeaders = new Headers(init?.headers ?? {});
|
|
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
|
}) as typeof globalThis.fetch;
|
|
try {
|
|
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
await req.json();
|
|
await fetchUserProfile(req);
|
|
assert.ok(capturedHeaders, "fetch was never called");
|
|
assert.equal(capturedHeaders!.get("content-type"), null, "content-type must be stripped from GET /me");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
}
|
|
|
|
// ── 5. non-2xx surfaces a thrown error (caller handles) ─────────────────────
|
|
{
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () => new Response("nope", { status: 503 })) as typeof globalThis.fetch;
|
|
try {
|
|
const req = new Request("https://backend.test/api/growqr/users/onboarding", { method: "GET" });
|
|
await assert.rejects(
|
|
() => fetchUserProfile(req),
|
|
/user-service \/me fetch failed: 503/,
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
}
|
|
|
|
// ── 6. synthetic PATCH /me must not carry a stale content-length ───────────
|
|
// Regression for the second PATCH 500: patchPreferencesRequest copied inbound
|
|
// headers including content-length, then set a DIFFERENT body — undici kept the
|
|
// stale length, user-service waited for bytes that never arrive, and closed
|
|
// the socket (~2.8s "other side closed"). The Request constructor does NOT
|
|
// recompute content-length when an explicit body is provided alongside
|
|
// forwarded headers, so the deletes inside patchPreferencesRequest are
|
|
// load-bearing. This tests the REAL exported function.
|
|
{
|
|
// Inbound request with a body whose length differs from the synthetic body.
|
|
const inboundBody = JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 });
|
|
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"content-length": String(Buffer.byteLength(inboundBody)),
|
|
"transfer-encoding": "chunked",
|
|
},
|
|
body: inboundBody,
|
|
});
|
|
|
|
const preferences = { onboarding: { access_choice: "trial" } };
|
|
const synthetic = patchPreferencesRequest(req, preferences);
|
|
|
|
// The synthetic request must not carry the stale inbound length/transfer headers.
|
|
assert.equal(synthetic.headers.get("content-length"), null, "stale content-length must be stripped from synthetic PATCH /me");
|
|
assert.equal(synthetic.headers.get("transfer-encoding"), null, "transfer-encoding must be stripped from synthetic PATCH /me");
|
|
assert.equal(synthetic.headers.get("host"), null, "host must be stripped");
|
|
assert.equal(synthetic.headers.get("cookie"), null, "cookie must be stripped");
|
|
assert.equal(synthetic.headers.get("content-type"), "application/json", "content-type must be set");
|
|
assert.equal(synthetic.method, "PATCH", "method must be PATCH");
|
|
assert.ok(synthetic.url.endsWith("/api/v1/users/me"), `target must be /me, got ${synthetic.url}`);
|
|
|
|
// The body must be the synthetic preferences blob, not the inbound payload.
|
|
const sentBody = await synthetic.text();
|
|
assert.deepEqual(JSON.parse(sentBody), { preferences }, "synthetic body must carry only { preferences }");
|
|
assert.notEqual(sentBody.length, Buffer.byteLength(inboundBody), "synthetic body length must differ from inbound (else the test proves nothing)");
|
|
}
|
|
|
|
console.log("user-profile: all assertions passed");
|