Files
growqr-backend/scripts/user-profile.test.ts
2026-07-13 20:12:59 +05:30

307 lines
15 KiB
TypeScript

import assert from "node:assert/strict";
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<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)");
}
// ── 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<string, any>).onboarding.revision, 10);
const onboarding = (profile.preferences as Record<string, any>).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");
}
console.log("user-profile: all assertions passed");