From 06a59e1b5c6b0d049f0f6d8abce2b5e5fea4ddb7 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 15:38:45 +0100 Subject: [PATCH] fix(sdk): block real network in test context, strip query from HttpError message Co-Authored-By: Claude Opus 5.5 --- packages/plugin-sdk/src/http.ts | 12 +++++++++++- packages/plugin-sdk/src/testing.ts | 6 +++++- packages/plugin-sdk/test/http.test.ts | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/plugin-sdk/src/http.ts b/packages/plugin-sdk/src/http.ts index e596d25..ac13029 100644 --- a/packages/plugin-sdk/src/http.ts +++ b/packages/plugin-sdk/src/http.ts @@ -1,10 +1,20 @@ import type { HttpClient } from "./types.ts"; +/** Origin + pathname only: query strings may carry API keys or tokens. */ +function safeUrl(url: string): string { + try { + const u = new URL(url); + return u.origin + u.pathname; + } catch { + return url.split(/[?#]/)[0] ?? ""; + } +} + export class HttpError extends Error { readonly status: number; readonly body: string; constructor(status: number, body: string, url = "") { - super(`HTTP ${status}${url ? ` for ${url}` : ""}`); + super(`HTTP ${status}${url ? ` for ${safeUrl(url)}` : ""}`); this.name = "HttpError"; this.status = status; this.body = body; diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 8b8170e..f48678d 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -21,6 +21,10 @@ export function createFixtureHttp(routes: Record): { fetch: typeo return { fetch: fn as typeof fetch, calls }; } +const noFetch = (async () => { + throw new Error("createTestContext: no fetch provided"); +}) as unknown as typeof fetch; + export function createTestContext( partial: Partial & { fetch?: typeof fetch } = {}, ): PluginContext & { saved: { credentials: Record[]; cursor: string[] } } { @@ -29,7 +33,7 @@ export function createTestContext( let creds: Record | null = null; let cursor: string | null = null; const ctx: PluginContext = { - http: createHttpClient({ userAgent: "sttracker-test/0", fetchImpl, sleep: async () => {} }), + http: createHttpClient({ userAgent: "sttracker-test/0", fetchImpl: fetchImpl ?? noFetch, sleep: async () => {} }), log: () => {}, config: {}, credentials: { diff --git a/packages/plugin-sdk/test/http.test.ts b/packages/plugin-sdk/test/http.test.ts index ae47907..55bed93 100644 --- a/packages/plugin-sdk/test/http.test.ts +++ b/packages/plugin-sdk/test/http.test.ts @@ -104,6 +104,22 @@ describe("http client", () => { }); }); +describe("safety", () => { + test("HttpError message does not leak the query string", async () => { + const { fetchImpl } = seq(res(404, {})); + const http = createHttpClient({ userAgent: "t/1", fetchImpl, sleep: async () => {} }); + const err = (await http.json("https://x.test/a?api_key=SECRET").catch((e) => e)) as HttpError; + expect(err).toBeInstanceOf(HttpError); + expect(err.message).not.toContain("SECRET"); + expect(err.message).toContain("https://x.test/a"); + }); + + test("createTestContext without fetch never hits the network", async () => { + const ctx = createTestContext(); + await expect(ctx.http.json("https://x.test/a")).rejects.toThrow("createTestContext: no fetch provided"); + }); +}); + describe("testing harness", () => { test("fixture routes match by full url or url without query; unknown throws", async () => { const fx = createFixtureHttp({