Files
gstack/browse/test/extension-token.test.ts
T
garrytan b421bba2c9 Merge origin/main (v1.91.7.0) into test-audit-reduction
Keep both intents: v1.91.7.0's functional QA, docsync and exploratory
paid cases and their free owners stay; this branch's deletions stay
deleted. main's new paid keys follow the derived-closure touchfile rule
(free *.test.ts paths dropped, static helper/fixture closure added), its
new helper-only tests join the ratchet baseline, and its free selection
examples that named free test files now assert the derived selection.

Periodic CI keeps seven slices without the retired Autoplan slice; the
gate census keeps seven single-worker slices with --skip-judges. Wall
and census literals are recomputed from the merged planner, durations
are re-recorded on Ubicloud, and VERSION stays 1.91.8.0 above 1.91.7.0.
2026-09-29 13:48:02 +00:00

212 lines
8.0 KiB
TypeScript

/**
* Live behavioral tests for the v1.62 token-bootstrap contract:
*
* - GET /health NEVER carries a token — not in headed mode, not for a
* chrome-extension:// Origin (the two pre-v1.62 carve-outs). IRON-RULE
* regression tests.
* - POST /extension-token releases the token ONLY to the pinned extension
* Origin (chrome-extension://GSTACK_EXTENSION_ID) with a loopback Host.
* - Host arrives with a port ('127.0.0.1:34567') and must be parsed to a
* hostname, not compared literally (amendment C9). 'localhost:34567'
* is accepted too.
* - The tunnel surface 404s /extension-token (not in TUNNEL_PATHS).
*
* Uses the buildFetchHandler factory (same pattern as server-factory.test.ts)
* so no listener/browser is needed. Real-HTTP coverage (Host header set by
* the network stack) lives in pair-agent-e2e.test.ts.
*/
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import * as crypto from 'crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
buildFetchHandler,
GSTACK_EXTENSION_ID,
type ServerConfig,
} from '../src/server';
import { __resetRegistry } from '../src/token-registry';
import { BrowserManager } from '../src/browser-manager';
import { resolveConfig } from '../src/config';
const PINNED_ORIGIN = `chrome-extension://${GSTACK_EXTENSION_ID}`;
const fixtureDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-extension-token-')));
const fixtureConfig = resolveConfig({ BROWSE_STATE_FILE: path.join(fixtureDir, 'state/browse.json') });
afterAll(() => {
fs.rmSync(fixtureDir, { recursive: true, force: true });
});
function makeConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
const token = 'ext-token-test-' + crypto.randomBytes(16).toString('hex');
return {
authToken: token,
browsePort: 34567,
idleTimeoutMs: 1_800_000,
config: fixtureConfig,
browserManager: new BrowserManager(),
ownsTerminalAgent: false,
startTime: Date.now(),
...overrides,
};
}
function headedBrowserManager(): BrowserManager {
const bm = new BrowserManager();
// connectionMode is private; force the headed value the old /health
// carve-out keyed on.
(bm as any).connectionMode = 'headed';
return bm;
}
function tokenRequest(headers: Record<string, string>): Request {
// Direct handler invocation — no network stack to synthesize Host, so
// every test sets it explicitly (Bun.serve always delivers one).
return new Request('http://127.0.0.1:34567/extension-token', {
method: 'POST',
headers,
});
}
describe('GET /health never carries a token (IRON RULE)', () => {
beforeEach(() => __resetRegistry());
test('headed mode: no token field in the body', async () => {
const handle = buildFetchHandler(makeConfig({ browserManager: headedBrowserManager() }));
const resp = await handle.fetchLocal(new Request('http://127.0.0.1:34567/health'), null);
expect(resp.status).toBe(200);
const body = await resp.json() as any;
expect(body.token).toBeUndefined();
expect(body.mode).toBe('headed');
});
test('chrome-extension Origin (even the pinned one): no token field', async () => {
const handle = buildFetchHandler(makeConfig());
const resp = await handle.fetchLocal(new Request('http://127.0.0.1:34567/health', {
headers: { Origin: PINNED_ORIGIN },
}), null);
expect(resp.status).toBe(200);
const body = await resp.json() as any;
expect(body.token).toBeUndefined();
});
test('headed mode AND pinned chrome-extension Origin together: still no token', async () => {
const handle = buildFetchHandler(makeConfig({ browserManager: headedBrowserManager() }));
const resp = await handle.fetchLocal(new Request('http://127.0.0.1:34567/health', {
headers: { Origin: PINNED_ORIGIN },
}), null);
const body = await resp.json() as any;
expect(body.token).toBeUndefined();
});
});
describe('GET /health is liveness-only', () => {
beforeEach(() => __resetRegistry());
// Folds the former server-auth / security-audit-r2 / sidebar-tabs /
// server-security-surface source greps into one check on the real body.
// #2557: no `security` field (its only data source had no writer).
const FORBIDDEN = ['token', 'security', 'currentUrl', 'currentMessage', 'agentStatus', 'messageQueue', 'agentStartTime', 'chatEnabled'];
for (const [label, browserManager, headers] of [
['default mode', () => new BrowserManager(), {}],
['headed mode + pinned extension Origin', headedBrowserManager, { Origin: PINNED_ORIGIN }],
] as const) {
test(`${label}: no token, security, browsing-state or chat fields; terminal port survives`, async () => {
const handle = buildFetchHandler(makeConfig({ browserManager: browserManager() }));
const resp = await handle.fetchLocal(new Request('http://127.0.0.1:34567/health', { headers }), null);
expect(resp.status).toBe(200);
const body = await resp.json() as Record<string, unknown>;
expect(FORBIDDEN.filter((key) => key in body)).toEqual([]);
expect('terminalPort' in body).toBe(true);
});
}
});
describe('POST /extension-token pinned-origin bootstrap', () => {
beforeEach(() => __resetRegistry());
test('pinned Origin + Host with port → 200 with the token', async () => {
const cfg = makeConfig();
const handle = buildFetchHandler(cfg);
const resp = await handle.fetchLocal(tokenRequest({
Origin: PINNED_ORIGIN,
Host: '127.0.0.1:34567',
}), null);
expect(resp.status).toBe(200);
const body = await resp.json() as any;
expect(body.token).toBe(cfg.authToken);
});
test("Host 'localhost:34567' is accepted too (C9 hostname parse)", async () => {
const cfg = makeConfig();
const handle = buildFetchHandler(cfg);
const resp = await handle.fetchLocal(tokenRequest({
Origin: PINNED_ORIGIN,
Host: 'localhost:34567',
}), null);
expect(resp.status).toBe(200);
const body = await resp.json() as any;
expect(body.token).toBe(cfg.authToken);
});
test('wrong extension Origin → 403, no token, no detail', async () => {
const handle = buildFetchHandler(makeConfig());
const resp = await handle.fetchLocal(tokenRequest({
Origin: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
Host: '127.0.0.1:34567',
}), null);
expect(resp.status).toBe(403);
const body = await resp.json() as any;
expect(body.token).toBeUndefined();
// No detail about WHICH check failed
expect(JSON.stringify(body)).not.toContain('origin');
expect(JSON.stringify(body)).not.toContain('host');
});
test('missing Origin → 403', async () => {
const handle = buildFetchHandler(makeConfig());
const resp = await handle.fetchLocal(tokenRequest({
Host: '127.0.0.1:34567',
}), null);
expect(resp.status).toBe(403);
});
test('web-page Origin → 403 (DNS-rebinding page cannot mint a token)', async () => {
const handle = buildFetchHandler(makeConfig());
const resp = await handle.fetchLocal(tokenRequest({
Origin: 'http://evil.example.com',
Host: '127.0.0.1:34567',
}), null);
expect(resp.status).toBe(403);
});
test('non-loopback Host → 403 even with the pinned Origin', async () => {
const handle = buildFetchHandler(makeConfig());
const resp = await handle.fetchLocal(tokenRequest({
Origin: PINNED_ORIGIN,
Host: 'evil.example.com:34567',
}), null);
expect(resp.status).toBe(403);
});
test('malformed Host → 403, not a crash', async () => {
const handle = buildFetchHandler(makeConfig());
const resp = await handle.fetchLocal(tokenRequest({
Origin: PINNED_ORIGIN,
Host: ':::not a host:::',
}), null);
expect(resp.status).toBe(403);
});
test('tunnel surface 404s /extension-token (not in TUNNEL_PATHS)', async () => {
const handle = buildFetchHandler(makeConfig());
const resp = await handle.fetchTunnel(tokenRequest({
Origin: PINNED_ORIGIN,
Host: '127.0.0.1:34567',
}), null);
expect(resp.status).toBe(404);
});
});