Files
gstack/test/gstack2-runtime-context.test.ts
T

635 lines
24 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { main } from "../runtime/cli.js";
import { configSet, loadConfig } from "../runtime/config.js";
import {
ContextClient,
assertPublicRequestOptions,
assertPublicUrl,
assertPublicUrlResolved,
mapContextFailure,
readContextKey,
redactSensitiveText,
validateContextKey,
} from "../runtime/context.js";
const consented = {
network: { mode: "context", consent: true, selection: "context" },
context: { baseUrl: "https://api.context.dev/v1" },
};
describe("Context.dev privacy and failure contract", () => {
test("fallback choices persist without granting Context.dev consent", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-context-choice-"));
const home = path.join(root, "state");
let output = "";
const stream = { write: (value: string) => { output += value; } };
try {
expect(await main(["context", "options"], { env: { GSTACK_HOME: home }, cwd: root, stdout: stream, stderr: stream })).toBe(0);
expect(output).toContain("A) Set up Context.dev free");
output = "";
expect(await main(["context", "select", "host"], { env: { GSTACK_HOME: home }, cwd: root, stdout: stream, stderr: stream })).toBe(0);
expect(await loadConfig(home)).toMatchObject({ network: { mode: "host", consent: false, selection: "host" } });
expect(await main(["context", "select", "local-browser"], { env: { GSTACK_HOME: home }, cwd: root, stdout: stream, stderr: stream })).toBe(0);
expect(await loadConfig(home)).toMatchObject({ network: { mode: "local-browser", consent: false, selection: "local-browser" } });
expect(await main(["context", "select", "none"], { env: { GSTACK_HOME: home }, cwd: root, stdout: stream, stderr: stream })).toBe(0);
expect(await loadConfig(home)).toMatchObject({ network: { mode: "off", consent: false, selection: "none" } });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test("Context setup persists one coherent selected network choice", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-context-setup-"));
const home = path.join(root, "state");
const stream = { write: (_value: string) => {} };
try {
expect(await main(["context", "setup", "--consent"], {
env: { GSTACK_HOME: home, CONTEXT_DEV_API_KEY: "future-format-12345" },
cwd: root,
stdout: stream,
stderr: stream,
})).toBe(0);
expect(await loadConfig(home)).toMatchObject({
network: { mode: "context", consent: true, selection: "context" },
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test("public config rejects nested secret-shaped fields", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-context-config-"));
const home = path.join(root, "state");
const stream = { write: (_value: string) => {} };
try {
expect(await main(["context", "select", "none"], {
env: { GSTACK_HOME: home }, cwd: root, stdout: stream, stderr: stream,
})).toBe(0);
for (const key of ["oauth.clientSecret", "auth.session", "service.cookie", "tls.privateKey"]) {
let error: any;
try {
await configSet(home, key, "must-not-be-public");
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("SECRET_IN_CONFIG");
}
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
test("public URL gate rejects credentials, loopback, link-local, private IPs, and private-ish names", () => {
const opaqueCredential = "Q7vN2xLm9Pz4Ks8Wd3Hj6Rc1Ty5Ua0Be";
const hyphenatedOpaqueCredential = "Q7vN2xLm-9Pz4Ks8W-d3Hj6Rc1-Ty5Ua0Be";
const blocked = [
"http://user:password@example.com",
"http://localhost/path",
"http://service.internal/path",
"http://10.1.2.3/path",
"http://127.0.0.1/path",
"http://0x7f000001/path",
"http://169.254.169.254/latest/meta-data",
"http://192.168.4.5/path",
"http://[::1]/path",
"http://[fe80::1]/path",
"http://[fec0::1]/path",
"http://[64:ff9b:1::1]/path",
"http://[::ffff:127.0.0.1]/path",
"file:///etc/passwd",
"https://example.com/report?access_token=secret",
"https://example.com/download?X-Amz-Signature=secret",
"https://example.com/download?X-Goog-Signature=secret",
"https://example.com/download?client_secret=short",
"https://example.com/download?refresh-token=short",
"https://example.com/download?cookie=session-value",
"https://example.com/download?access_to%256ben=abc",
"https://example.com/download?client_se%2563ret=abc",
"https://example.com/download?coo%256bie=abc",
"https://example.com/oauth/callback#access_token=secret",
"https://example.com/oauth/callback#id_token=secret",
`https://example.com/download/${opaqueCredential}`,
`https://example.com/download?document=${opaqueCredential}`,
`https://example.com/download/${hyphenatedOpaqueCredential}`,
"https://example.com/download/sk_aaaaaaaaaaaaaaaa",
];
for (const target of blocked) {
try {
assertPublicUrl(target);
throw new Error(`gate accepted ${target}`);
} catch (error: any) {
expect(error.code).toBe("CONTEXT_BLOCKED");
}
}
expect(assertPublicUrl("https://example.com/path").hostname).toBe("example.com");
expect(assertPublicUrl("https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie").hostname).toBe("developer.mozilla.org");
expect(assertPublicUrl("https://www.rfc-editor.org/rfc/rfc6750?topic=access-token").hostname).toBe("www.rfc-editor.org");
expect(assertPublicUrl("https://example.com/posts/sphinx-of-black-quartz-judge-my-vow-2026").hostname).toBe("example.com");
expect(assertPublicUrl("https://example.com/posts/Introducing-GStack2-for-public-web-research-2026").hostname).toBe("example.com");
expect(assertPublicUrl("https://example.com/resources/550e8400-e29b-41d4-a716-446655440000").hostname).toBe("example.com");
expect(assertPublicUrl("https://example.com/resources/00000000-0000-0000-0000-000000000000").hostname).toBe("example.com");
expect(assertPublicUrl("https://example.com/search?q=this-is-a-long-public-article-slug-2026").hostname).toBe("example.com");
expect(assertPublicUrl("https://8.8.8.8/").hostname).toBe("8.8.8.8");
});
test("nested percent encoding cannot hide credentials in paths, queries, or fragments", () => {
let nestedCredential = "sk%2Dlive%5Fexample%5F1234567890";
for (let layer = 0; layer < 5; layer += 1) nestedCredential = encodeURIComponent(nestedCredential);
for (const target of [
`https://example.com/download/${nestedCredential}`,
`https://example.com/download?q=${nestedCredential}`,
`https://example.com/download#q=${nestedCredential}`,
]) {
let error: any;
try {
assertPublicUrl(target);
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
}
let nestedLabel = [..."access_token"]
.map((character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("");
for (let layer = 0; layer < 5; layer += 1) nestedLabel = encodeURIComponent(nestedLabel);
for (const target of [
`https://example.com/download?${nestedLabel}=ordinary-password-value`,
`https://example.com/download#${nestedLabel}=ordinary-password-value`,
]) {
let error: any;
try {
assertPublicUrl(target);
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
}
});
test("unknown and private request fields are rejected by endpoint allowlists", async () => {
for (const [endpoint, options] of [
["scrapeMarkdown", { headers: { Authorization: "Bearer secret" } }],
["scrapeMarkdown", { auth: "Bearer secret" }],
["scrapeHtml", { jwt: "secret" }],
["crawl", { prompt: "private repository contents" }],
["sitemap", { "private-repo": "owner/private" }],
["screenshot", { directUrl: "https://example.com", viewport: { width: 1280, privateKey: "secret" } }],
["scrapeMarkdown", { pdf: { shouldParse: true, arbitrary: { nested: "secret" } } }],
]) {
expect(() => assertPublicRequestOptions(endpoint, options)).toThrow();
try {
assertPublicRequestOptions(endpoint, options);
} catch (error: any) {
expect(error.code).toBe("CONTEXT_BLOCKED");
}
}
});
test("secret-shaped values are rejected even under allowlisted nested option names", () => {
const opaqueCredential = "Q7vN2xLm9Pz4Ks8Wd3Hj6Rc1Ty5Ua0Be";
for (const options of [
{ includeSelectors: [`[data-document='${opaqueCredential}']`] },
{ pdf: { shouldParse: true }, country: `us-${opaqueCredential}` },
]) {
let error: any;
try {
assertPublicRequestOptions("scrapeMarkdown", options);
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
expect(String(error?.message)).not.toContain(opaqueCredential);
}
});
test("a URL object cannot validate one value and serialize a secret-bearing value later", async () => {
const opaqueCredential = "Q7vN2xLm9Pz4Ks8Wd3Hj6Rc1Ty5Ua0Be";
const target = new URL("https://example.com/public");
Object.defineProperty(target, "toString", {
value: () => `https://example.com/download/${opaqueCredential}`,
});
let fetches = 0;
const client = new ContextClient({
config: consented,
key: "future-format-credential-12345",
resolveDns: false,
fetch: async () => {
fetches += 1;
return new Response("{}");
},
});
let error: any;
try {
await client.scrapeMarkdown(target);
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
expect(fetches).toBe(0);
});
test("endpoint allowlists preserve documented public extraction controls", () => {
const allowed = [
["scrapeMarkdown", {
includeLinks: false,
includeImages: true,
shortenBase64Images: true,
useMainContentOnly: true,
pdf: { shouldParse: true, start: 1, end: 2, ocr: false },
includeFrames: false,
includeSelectors: ["main"],
excludeSelectors: ["nav"],
maxAgeMs: 0,
waitForMs: 100,
settleAnimations: true,
country: "us",
timeoutMS: 1_000,
}],
["scrapeHtml", {
pdf: { shouldParse: true, ocr: false },
includeFrames: false,
useMainContentOnly: true,
includeSelectors: ["article"],
excludeSelectors: ["footer"],
maxAgeMs: 0,
waitForMs: 100,
settleAnimations: false,
country: "us",
timeoutMS: 1_000,
}],
["crawl", {
maxPages: 2,
maxDepth: 1,
urlRegex: "^https://example\\.com/docs",
includeLinks: true,
includeImages: false,
shortenBase64Images: true,
useMainContentOnly: true,
followSubdomains: false,
pdf: { shouldParse: true, start: 1, end: 2, ocr: false },
includeFrames: false,
includeSelectors: ["main"],
excludeSelectors: ["nav"],
maxAgeMs: 0,
waitForMs: 100,
settleAnimations: false,
stopAfterMs: 10_000,
country: "us",
timeoutMS: 20_000,
}],
["sitemap", {
maxLinks: 3,
sitemapUrl: "https://example.com/sitemap.xml",
urlRegex: "^https://example\\.com/docs",
timeoutMS: 1_000,
}],
["screenshot", {
directUrl: "https://example.com/pricing",
fullScreenshot: false,
waitForMs: 100,
viewport: { width: 1280, height: 720 },
handleCookiePopup: true,
colorScheme: "dark",
scrollOffset: 0,
maxAgeMs: 0,
country: "us",
timeoutMS: 1_000,
}],
];
for (const [endpoint, options] of allowed) {
expect(assertPublicRequestOptions(endpoint, options)).toEqual(options);
}
});
test("DNS rebinding/private resolution is rejected before fetch", async () => {
let error: any;
try {
await assertPublicUrlResolved("https://public.example.com", {
lookup: async () => [{ address: "10.0.0.9", family: 4 }],
});
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
});
test("DNS resolution is covered by the public-operation timeout", async () => {
let fetches = 0;
const client = new ContextClient({
config: consented,
key: "future-format-credential-12345",
timeoutMs: 20,
lookup: async () => new Promise(() => {}),
fetch: async () => {
fetches += 1;
return new Response("{}");
},
});
const started = Date.now();
await expect(client.scrapeMarkdown("https://example.com")).rejects.toMatchObject({ code: "CONTEXT_TIMEOUT" });
expect(Date.now() - started).toBeLessThan(1_000);
expect(fetches).toBe(0);
});
test("network and DNS remain untouched unless selection, mode, and consent all select Context.dev", async () => {
let lookups = 0;
let fetches = 0;
for (const config of [
{ network: { mode: "off", consent: false, selection: "none" } },
{ network: { mode: "context", consent: true, selection: "host" } },
{ network: { mode: "context", consent: true, selection: null } },
{ network: { mode: "context", consent: true } },
]) {
const client = new ContextClient({
config,
key: "ctxt_secret_12345678",
lookup: async () => {
lookups += 1;
return [{ address: "93.184.216.34", family: 4 }];
},
fetch: async () => {
fetches += 1;
return new Response("{}");
},
});
let error: any;
try {
await client.scrapeMarkdown("https://example.com");
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
}
expect(lookups).toBe(0);
expect(fetches).toBe(0);
});
test("revoking the Context.dev selection after DNS still prevents fetch", async () => {
const config = {
network: { mode: "context", consent: true, selection: "context" },
context: { baseUrl: "https://api.context.dev/v1" },
};
let lookups = 0;
let fetches = 0;
const client = new ContextClient({
config,
key: "ctxt_secret_12345678",
lookup: async () => {
lookups += 1;
config.network.selection = "host";
return [{ address: "93.184.216.34", family: 4 }];
},
fetch: async () => {
fetches += 1;
return new Response("{}");
},
});
let error: any;
try {
await client.scrapeMarkdown("https://example.com");
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
expect(lookups).toBe(1);
expect(fetches).toBe(0);
});
test("blocked endpoint options perform zero DNS lookups and zero fetches", async () => {
let lookups = 0;
let fetches = 0;
const client = new ContextClient({
config: consented,
key: "ctxt_secret_12345678",
lookup: async () => {
lookups += 1;
return [{ address: "93.184.216.34", family: 4 }];
},
fetch: async () => {
fetches += 1;
return new Response("{}");
},
});
const attempts = [
() => client.scrapeMarkdown("https://example.com", { auth: "secret" }),
() => client.scrapeHtml("https://example.com", { jwt: "secret" }),
() => client.crawl("https://example.com", { prompt: "private" }),
() => client.sitemap("example.com", { "private-repo": "owner/private" }),
() => client.screenshot("https://example.com", { viewport: { width: 1280, prompt: "private" } }),
];
for (const attempt of attempts) {
let error: any;
try {
await attempt();
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BLOCKED");
}
expect(lookups).toBe(0);
expect(fetches).toBe(0);
});
test("validated options are snapshotted before any asynchronous work", async () => {
let reads = 0;
let requestUrl: URL | undefined;
const options = Object.defineProperty({}, "includeLinks", {
enumerable: true,
get: () => (++reads === 1 ? false : { prompt: "private" }),
});
const client = new ContextClient({
config: consented,
key: "ctxt_secret_12345678",
resolveDns: false,
fetch: async (url: URL) => {
requestUrl = new URL(url);
return new Response(JSON.stringify({ success: true }), { status: 200 });
},
});
await client.scrapeMarkdown("https://example.com", options);
expect(reads).toBe(1);
expect(requestUrl?.searchParams.get("includeLinks")).toBe("false");
expect(requestUrl?.search).not.toContain("prompt");
});
test("documented scrape and crawl endpoints use authenticated JSON without dependencies", async () => {
const calls: Array<{ url: URL; init: RequestInit }> = [];
const client = new ContextClient({
config: consented,
key: "ctxt_secret_12345678",
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
fetch: async (url: URL, init: RequestInit) => {
calls.push({ url: new URL(url), init });
return new Response(JSON.stringify({ success: true, markdown: "hello" }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
});
await client.scrapeMarkdown("https://example.com/docs", { includeLinks: false });
await client.scrapeHtml("https://example.com/docs");
await client.crawl("https://example.com", { maxPages: 2 });
await client.sitemap("example.com", { maxLinks: 3 });
await client.screenshot("https://example.com/pricing", { fullScreenshot: false });
expect(calls.map((call) => call.url.pathname)).toEqual([
"/v1/web/scrape/markdown",
"/v1/web/scrape/html",
"/v1/web/crawl",
"/v1/web/scrape/sitemap",
"/v1/screenshot",
]);
expect(calls[0].url.searchParams.get("url")).toBe("https://example.com/docs");
expect(JSON.parse(String(calls[2].init.body))).toEqual({ maxPages: 2, url: "https://example.com" });
expect(String((calls[0].init.headers as Record<string, string>).Authorization).startsWith("Bearer ")).toBe(true);
});
test("custom Context origins can never receive credentials, even with the legacy override flag", async () => {
let fetches = 0;
const client = new ContextClient({
config: consented,
key: "future-format-credential-12345",
baseUrl: "https://credential-collector.example/v1",
allowCustomBaseUrl: true,
resolveDns: false,
fetch: async () => {
fetches += 1;
return new Response("{}");
},
});
let error: any;
try {
await client.scrapeMarkdown("https://example.com");
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BAD_RESPONSE");
expect(fetches).toBe(0);
});
test("provider echoes and fetch causes redact arbitrary current and future credential formats", async () => {
const key = "future-format-credential-12345";
const responseClient = new ContextClient({
config: consented,
key,
resolveDns: false,
fetch: async () => new Response(JSON.stringify({
error_code: "UNAUTHORIZED",
message: `provider rejected ${key}`,
metadata: { echoedCredential: key },
}), { status: 401 }),
});
let responseError: any;
try {
await responseClient.scrapeMarkdown("https://example.com");
} catch (caught) {
responseError = caught;
}
expect(responseError?.code).toBe("CONTEXT_KEY_INVALID");
expect(responseError?.message).toContain("[REDACTED]");
expect(JSON.stringify(responseError)).not.toContain(key);
expect(JSON.stringify(responseError?.details)).not.toContain(key);
const causeClient = new ContextClient({
config: consented,
key,
resolveDns: false,
fetch: async (_url: URL, init: RequestInit) => {
throw new Error(`transport logged ${(init.headers as Record<string, string>).Authorization}`);
},
});
let causeError: any;
try {
await causeClient.scrapeMarkdown("https://example.com");
} catch (caught) {
causeError = caught;
}
expect(causeError?.cause?.message).toContain("[REDACTED]");
expect(causeError?.cause?.message).not.toContain(key);
expect(causeError?.message).not.toContain(key);
});
test("redaction catches credential syntax without rewriting ordinary prose or commit hashes", () => {
const ordinary = "The monkey: banana sketch uses tokenization at commit bb57306d98c97011b0919c6132705a15b1579781.";
expect(redactSensitiveText(ordinary)).toBe(ordinary);
expect(redactSensitiveText("Authorization: Bearer unknown_future_Ab3K9mP2qR7sT4vW8xY1z"))
.toBe("Authorization: [REDACTED]");
expect(redactSensitiveText("api_key=unknown_future_Ab3K9mP2qR7sT4vW8xY1z")).not.toContain("Ab3K9mP2");
});
test("request timeout covers a response body that stalls after headers", async () => {
const client = new ContextClient({
config: consented,
key: "future-format-credential-12345",
resolveDns: false,
timeoutMs: 20,
fetch: async (_url: URL, init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
text: () => new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
}),
} as Response),
});
const started = Date.now();
let error: any;
try {
await client.scrapeMarkdown("https://example.com");
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_TIMEOUT");
expect(Date.now() - started).toBeLessThan(1_000);
});
test("failure taxonomy is stable and exact", async () => {
let missing: any;
try {
await readContextKey({ env: {} });
} catch (error) {
missing = error;
}
expect(missing?.code).toBe("CONTEXT_KEY_MISSING");
expect(() => validateContextKey("not-a-key")).toThrow();
try {
validateContextKey("not-a-key");
} catch (error: any) {
expect(error.code).toBe("CONTEXT_KEY_INVALID");
}
expect(validateContextKey("future-format-12345")).toBe("future-format-12345");
expect(mapContextFailure(403, { error_code: "FORBIDDEN", message: "Please verify your email" }).code).toBe("CONTEXT_EMAIL_UNVERIFIED");
expect(mapContextFailure(403, { error_code: "USAGE_EXCEEDED", message: "No credits" }).code).toBe("CONTEXT_CREDITS_EXHAUSTED");
expect(mapContextFailure(401, { message: "Credits remaining: 0" }).code).toBe("CONTEXT_CREDITS_EXHAUSTED");
expect(mapContextFailure(429, { error_code: "RATE_LIMITED" }).code).toBe("CONTEXT_RATE_LIMITED");
expect(mapContextFailure(408, { error_code: "REQUEST_TIMEOUT" }).code).toBe("CONTEXT_TIMEOUT");
expect(mapContextFailure(400, { error_code: "WEBSITE_ACCESS_ERROR" }).code).toBe("CONTEXT_BLOCKED");
expect(mapContextFailure(401, { error_code: "UNAUTHORIZED" }).code).toBe("CONTEXT_KEY_INVALID");
expect(mapContextFailure(500, { error_code: "INTERNAL_ERROR" }).code).toBe("CONTEXT_BAD_RESPONSE");
});
test("deprecated search is typed unsupported and performs no network", async () => {
let fetches = 0;
const client = new ContextClient({
config: consented,
key: "ctxt_secret_12345678",
fetch: async () => {
fetches += 1;
return new Response("{}");
},
});
let error: any;
try {
await client.search();
} catch (caught) {
error = caught;
}
expect(error?.code).toBe("CONTEXT_BAD_RESPONSE");
expect(error?.unsupported).toBe(true);
expect(fetches).toBe(0);
});
});