import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { config } from "../src/config.js"; import { resolveTrustedServiceTokens } from "../src/auth/clerk.js"; import { fetchOnboardingReadProfile, 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; 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)"); } // ── 7. trusted service auth uses A2A state with scoped identity ───────────── { const originalFetch = globalThis.fetch; const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string }; const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv }; Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" }); const userId = "user_3EX1LG3gBk3KY6kfD9PiTkWubs4/scope"; let target = ""; let auth = ""; let forwardedScope: string | null = null; globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => { target = String(input instanceof URL ? input : input.url); const headers = new Headers(init?.headers ?? {}); auth = headers.get("authorization") ?? ""; forwardedScope = headers.get("x-growqr-user"); return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10, status: "completed", access_choice: "trial", profile: { icp: "experienced" } } } }), { status: 200 }); }) as typeof globalThis.fetch; try { const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer test-service-token", "x-growqr-user": userId }, }); const profile = await fetchOnboardingReadProfile(req, userId); assert.ok(target.endsWith(`/api/state/${encodeURIComponent(userId)}`), `A2A target must be scoped to user: ${target}`); assert.equal(auth, "Bearer test-a2a-key", "A2A key must be used downstream"); assert.equal(forwardedScope, null, "user scope must be encoded in URL, not forwarded as trust header"); assert.equal((profile.preferences as Record).onboarding.revision, 10); const onboarding = (profile.preferences as Record).onboarding; assert.equal(onboarding.status, "completed"); assert.equal(onboarding.profile.icp, "experienced"); assert.equal(onboarding.access_choice, "trial"); } finally { Object.assign(mutableConfig, savedConfig); globalThis.fetch = originalFetch; } } // Trusted auth without a matching scope is rejected before any downstream call. { const originalFetch = globalThis.fetch; let calls = 0; const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string }; const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv }; Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" }); globalThis.fetch = (async () => { calls += 1; return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch; try { const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer test-service-token" } }); await assert.rejects(() => fetchOnboardingReadProfile(req, "user_spoof"), /matching x-growqr-user/); assert.equal(calls, 0, "unscoped trusted auth must not call user-service"); } finally { Object.assign(mutableConfig, savedConfig); globalThis.fetch = originalFetch; } } // Production never treats A2A_ALLOWED_KEY as an inbound backend token. { const originalFetch = globalThis.fetch; const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string }; const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv }; Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" }); let target = ""; let auth = ""; globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => { target = String(input instanceof URL ? input : input.url); auth = new Headers(init?.headers ?? {}).get("authorization") ?? ""; return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 }); }) as typeof globalThis.fetch; try { const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer test-a2a-key", "x-growqr-user": "user_spoof" }, }); await fetchOnboardingReadProfile(req, "user_real"); assert.ok(target.endsWith("/api/v1/users/me"), "production A2A key must use Clerk /me path"); assert.equal(auth, "Bearer test-a2a-key"); } finally { Object.assign(mutableConfig, savedConfig); globalThis.fetch = originalFetch; } } // Clerk JWTs always use /me, even when a spoofed x-growqr-user is present. { const originalFetch = globalThis.fetch; let target = ""; let auth = ""; let forwardedScope: string | null = null; globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => { target = String(input instanceof URL ? input : input.url); const headers = new Headers(init?.headers ?? {}); auth = headers.get("authorization") ?? ""; forwardedScope = headers.get("x-growqr-user"); return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 }); }) as typeof globalThis.fetch; try { const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer eyJ.clerk.jwt", "x-growqr-user": "user_spoof" }, }); await fetchOnboardingReadProfile(req, "user_real"); assert.ok(target.endsWith("/api/v1/users/me"), `Clerk target must be /me: ${target}`); assert.equal(auth, "Bearer eyJ.clerk.jwt", "Clerk JWT must be forwarded unchanged"); assert.equal(forwardedScope, null, "spoofable x-growqr-user must not be forwarded to /me"); } finally { globalThis.fetch = originalFetch; } } // An invalid Clerk JWT remains a /me error and never falls back to A2A. { const originalFetch = globalThis.fetch; let calls = 0; globalThis.fetch = (async () => { calls += 1; return new Response('{"detail":"invalid"}', { status: 401 }); }) as typeof globalThis.fetch; try { const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer invalid.jwt" } }); await assert.rejects(() => fetchOnboardingReadProfile(req, "user_real"), /user-service \/me fetch failed: 401/); assert.equal(calls, 1, "invalid Clerk JWT must not trigger an A2A fallback"); } finally { globalThis.fetch = originalFetch; } } // ── 8. trusted token resolution excludes A2A in production ──────────────── { assert.equal(config.a2aAllowedKey, process.env.A2A_ALLOWED_KEY ?? "", "A2A config must not have a dev fallback"); const production = resolveTrustedServiceTokens({ nodeEnv: "production", serviceToken: "service-token", a2aAllowedKey: "a2a-token", }); assert.equal(production.has("service-token"), true); assert.equal(production.has("a2a-token"), false, "production must exclude A2A token"); const development = resolveTrustedServiceTokens({ nodeEnv: "development", serviceToken: "service-token", a2aAllowedKey: "a2a-token", }); assert.equal(development.has("service-token"), true); assert.equal(development.has("a2a-token"), true, "development must include configured A2A token"); } // Compose must never inject a development service token in production. { const compose = readFileSync(new URL("../docker-compose.yml", import.meta.url), "utf8"); assert.doesNotMatch(compose, /dev-service-token/, "compose must not contain a development service token fallback"); assert.match(compose, /SERVICE_TOKEN:\s*\$\{SERVICE_TOKEN:-\}/, "compose SERVICE_TOKEN default must be explicitly empty"); } console.log("user-profile: all assertions passed");