307 lines
15 KiB
TypeScript
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");
|