Files
growqr-backend/scripts/user-profile.test.ts
-Puter c54e12972c fix(onboarding): synthetic PATCH /me stale content-length socket close
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.
2026-07-12 20:29:40 +05:30

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");