ui-server.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. /**
  2. * `codegraph ui` server — the loopback boundary (CG-41).
  3. *
  4. * This process serves the user's source code from a port on their machine, so
  5. * the tests that matter are the refusals: a foreign `Host` (DNS rebinding is
  6. * the only realistic attack on a loopback code viewer), a traversal out of the
  7. * asset root, a write method, a cross-origin read. The happy path — index.html
  8. * and hashed assets — is here mostly so a refusal that accidentally blocks
  9. * everything can't pass.
  10. *
  11. * Requests go through `http.request`, not `fetch`: `Host` is a forbidden header
  12. * name in undici, and forging it is the whole point of half these cases.
  13. */
  14. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  15. import * as http from 'http';
  16. import * as fs from 'fs';
  17. import * as os from 'os';
  18. import * as path from 'path';
  19. import {
  20. browserOpenCommand,
  21. cacheControlFor,
  22. contentTypeFor,
  23. isAllowedHost,
  24. isAllowedOrigin,
  25. isSafeRequestPath,
  26. PathRefusalError,
  27. resolveProjectFile,
  28. resolveStaticAsset,
  29. startUiServer,
  30. type UiServerHandle,
  31. } from '../src/ui-server';
  32. interface Response {
  33. status: number;
  34. headers: http.IncomingHttpHeaders;
  35. body: string;
  36. }
  37. /**
  38. * One request with full control over the request line and headers.
  39. *
  40. * `setHost: false` stops node from adding its own `Host`, and `path` is sent
  41. * verbatim — so a traversal case really does put `/../../x` on the wire.
  42. */
  43. function request(
  44. port: number,
  45. requestPath: string,
  46. options: { method?: string; headers?: Record<string, string> } = {}
  47. ): Promise<Response> {
  48. return new Promise((resolve, reject) => {
  49. const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, ...options.headers };
  50. const req = http.request(
  51. { host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false },
  52. (res) => {
  53. const chunks: Buffer[] = [];
  54. res.on('data', (c: Buffer) => chunks.push(c));
  55. res.on('end', () =>
  56. resolve({
  57. status: res.statusCode ?? 0,
  58. headers: res.headers,
  59. body: Buffer.concat(chunks).toString('utf-8'),
  60. })
  61. );
  62. }
  63. );
  64. req.on('error', reject);
  65. req.end();
  66. });
  67. }
  68. describe('codegraph ui server', () => {
  69. let tempDir: string;
  70. let viewerDir: string;
  71. let projectRoot: string;
  72. let server: UiServerHandle;
  73. beforeAll(async () => {
  74. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-'));
  75. // A stand-in for dist/viewer: same shape (index.html + hashed assets/), so
  76. // the tests don't need the Svelte build to have run.
  77. viewerDir = path.join(tempDir, 'viewer');
  78. fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true });
  79. fs.writeFileSync(
  80. path.join(viewerDir, 'index.html'),
  81. '<!doctype html><html><body><div id="app"></div>' +
  82. '<script type="module" src="./assets/index-abc123.js"></script></body></html>'
  83. );
  84. fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n');
  85. fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n');
  86. projectRoot = path.join(tempDir, 'project');
  87. fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
  88. fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
  89. // A file OUTSIDE both roots that a traversal would be trying to reach.
  90. fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
  91. server = await startUiServer({ projectRoot, viewerDir, port: 0 });
  92. });
  93. afterAll(async () => {
  94. await server?.close();
  95. fs.rmSync(tempDir, { recursive: true, force: true });
  96. });
  97. describe('serving the viewer', () => {
  98. it('serves index.html at the root', async () => {
  99. const res = await request(server.port, '/');
  100. expect(res.status).toBe(200);
  101. expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
  102. expect(res.body).toContain('<div id="app">');
  103. });
  104. it('serves index.html directly too', async () => {
  105. const res = await request(server.port, '/index.html');
  106. expect(res.status).toBe(200);
  107. expect(res.body).toContain('<div id="app">');
  108. });
  109. it('serves hashed assets with their real content type', async () => {
  110. const js = await request(server.port, '/assets/index-abc123.js');
  111. expect(js.status).toBe(200);
  112. expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8');
  113. expect(js.body).toContain('export const viewer');
  114. const css = await request(server.port, '/assets/index-abc123.css');
  115. expect(css.status).toBe(200);
  116. expect(css.headers['content-type']).toBe('text/css; charset=utf-8');
  117. });
  118. it('caches hashed assets forever and index.html never', async () => {
  119. const asset = await request(server.port, '/assets/index-abc123.js');
  120. expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');
  121. const index = await request(server.port, '/');
  122. expect(index.headers['cache-control']).toBe('no-store');
  123. });
  124. it('falls back to index.html for an unknown route, but not for a missing asset', async () => {
  125. // A hash-routed app only ever asks for `/`, but a hand-typed deep path
  126. // should still open the app.
  127. const route = await request(server.port, '/s/some-symbol-id');
  128. expect(route.status).toBe(200);
  129. expect(route.body).toContain('<div id="app">');
  130. // A missing FILE must 404 — answering with HTML would hand the browser a
  131. // script that isn't one, and hide a broken build.
  132. const asset = await request(server.port, '/assets/index-doesnotexist.js');
  133. expect(asset.status).toBe(404);
  134. });
  135. it('answers HEAD with the same headers and no body', async () => {
  136. const res = await request(server.port, '/', { method: 'HEAD' });
  137. expect(res.status).toBe(200);
  138. expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
  139. expect(res.headers['content-length']).toBeDefined();
  140. expect(res.body).toBe('');
  141. });
  142. });
  143. describe('binding', () => {
  144. it('listens on loopback only', () => {
  145. const address = server.server.address();
  146. expect(address).not.toBeNull();
  147. expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1');
  148. expect(server.url).toBe(`http://127.0.0.1:${server.port}`);
  149. });
  150. it('falls back to the next free port when the preferred one is taken', async () => {
  151. const blocker = http.createServer(() => {});
  152. await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
  153. const taken = (blocker.address() as { port: number }).port;
  154. const second = await startUiServer({ projectRoot, viewerDir, port: taken });
  155. try {
  156. expect(second.port).not.toBe(taken);
  157. expect(second.port).toBeGreaterThan(taken);
  158. // …and it actually works on the port it landed on.
  159. const res = await request(second.port, '/');
  160. expect(res.status).toBe(200);
  161. } finally {
  162. await second.close();
  163. await new Promise<void>((resolve) => blocker.close(() => resolve()));
  164. }
  165. });
  166. it('refuses to move off a port the caller pinned', async () => {
  167. const blocker = http.createServer(() => {});
  168. await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
  169. const taken = (blocker.address() as { port: number }).port;
  170. try {
  171. await expect(
  172. startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false })
  173. ).rejects.toThrow(/already in use/i);
  174. } finally {
  175. await new Promise<void>((resolve) => blocker.close(() => resolve()));
  176. }
  177. });
  178. });
  179. describe('Host allowlist (DNS rebinding)', () => {
  180. it('serves the loopback names', async () => {
  181. for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) {
  182. const res = await request(server.port, '/', { headers: { Host: host } });
  183. expect(res.status, `Host: ${host}`).toBe(200);
  184. }
  185. });
  186. it('refuses a foreign Host', async () => {
  187. for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) {
  188. const res = await request(server.port, '/', { headers: { Host: host } });
  189. expect(res.status, `Host: ${host}`).toBe(403);
  190. expect(res.body).not.toContain('<div id="app">');
  191. }
  192. });
  193. it('refuses a loopback Host carrying someone else\u2019s port', async () => {
  194. const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } });
  195. expect(res.status).toBe(403);
  196. });
  197. it('refuses a malformed or missing Host', async () => {
  198. const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } });
  199. expect(malformed.status).toBe(403);
  200. // Node's client insists on sending something for Host, so the empty-value
  201. // case is covered by the unit assertions on isAllowedHost below.
  202. });
  203. it('refuses before touching the filesystem — even for an asset', async () => {
  204. const res = await request(server.port, '/assets/index-abc123.js', {
  205. headers: { Host: 'evil.example' },
  206. });
  207. expect(res.status).toBe(403);
  208. expect(res.body).not.toContain('export const viewer');
  209. });
  210. });
  211. describe('cross-origin', () => {
  212. it('never sends CORS headers', async () => {
  213. const res = await request(server.port, '/');
  214. expect(res.headers['access-control-allow-origin']).toBeUndefined();
  215. expect(res.headers['access-control-allow-credentials']).toBeUndefined();
  216. expect(res.headers['access-control-allow-methods']).toBeUndefined();
  217. });
  218. it('refuses a request carrying a foreign Origin', async () => {
  219. const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } });
  220. expect(res.status).toBe(403);
  221. });
  222. it('allows the viewer\u2019s own origin', async () => {
  223. const res = await request(server.port, '/', {
  224. headers: { Origin: `http://127.0.0.1:${server.port}` },
  225. });
  226. expect(res.status).toBe(200);
  227. });
  228. it('sends the hardening headers on every response', async () => {
  229. const res = await request(server.port, '/');
  230. expect(res.headers['x-content-type-options']).toBe('nosniff');
  231. expect(res.headers['x-frame-options']).toBe('DENY');
  232. expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
  233. expect(res.headers['content-security-policy']).toContain("connect-src 'self'");
  234. });
  235. });
  236. describe('methods', () => {
  237. it('refuses every method it has never answered', async () => {
  238. for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) {
  239. const res = await request(server.port, '/', { method });
  240. expect(res.status, method).toBe(405);
  241. expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE');
  242. }
  243. });
  244. /**
  245. * The static side stayed a pure reader when `/api/trails` gained a write
  246. * (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the
  247. * narrower answer, since nothing under the viewer bundle will ever take
  248. * one.
  249. */
  250. it('refuses a write outside /api/, whatever it carries', async () => {
  251. for (const method of ['POST', 'DELETE']) {
  252. const res = await request(server.port, '/', {
  253. method,
  254. headers: { 'X-CodeGraph-UI': '1' },
  255. });
  256. expect(res.status, method).toBe(405);
  257. expect(res.headers['allow']).toBe('GET, HEAD');
  258. }
  259. });
  260. /**
  261. * Under `/api/` a write is answered as JSON even when refused — the viewer
  262. * parses these, and a text/plain body surfaces as a parse error rather than
  263. * the refusal it is. No API is mounted on this server, so the refusal is
  264. * the boundary's own and not an endpoint's.
  265. */
  266. it('refuses an unmarked write under /api/ as JSON', async () => {
  267. const res = await request(server.port, '/api/trails', { method: 'POST' });
  268. expect(res.status).toBe(403);
  269. expect(res.headers['content-type']).toContain('application/json');
  270. expect(JSON.parse(res.body).code).toBe('refused');
  271. });
  272. });
  273. describe('paths outside the asset root', () => {
  274. const traversals = [
  275. '/../secret.txt',
  276. '/../../secret.txt',
  277. '/assets/../../secret.txt',
  278. '/..%2fsecret.txt',
  279. '/%2e%2e/secret.txt',
  280. '/%2e%2e%2fsecret.txt',
  281. '/....//secret.txt',
  282. ];
  283. it('never serves a file outside the viewer directory', async () => {
  284. for (const traversal of traversals) {
  285. const res = await request(server.port, traversal);
  286. expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE');
  287. expect(res.status, traversal).not.toBe(200);
  288. }
  289. });
  290. it('404s an absolute system path rather than reading it', async () => {
  291. const res = await request(server.port, '/etc/passwd');
  292. expect(res.body).not.toContain('root:');
  293. // No such file under the viewer root; an extension-less path is a route.
  294. expect(res.status).toBe(200);
  295. expect(res.body).toContain('<div id="app">');
  296. const shadow = await request(server.port, '/etc/hosts.txt');
  297. expect(shadow.status).toBe(404);
  298. });
  299. it('404s a NUL-truncation attempt', async () => {
  300. const res = await request(server.port, '/index.html%00.png');
  301. expect(res.status).toBe(404);
  302. });
  303. });
  304. describe('/api is reserved', () => {
  305. it('404s as JSON, never as the app shell', async () => {
  306. const res = await request(server.port, '/api/nodes');
  307. expect(res.status).toBe(404);
  308. expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
  309. expect(JSON.parse(res.body)).toHaveProperty('error');
  310. expect(res.body).not.toContain('<div id="app">');
  311. });
  312. it('hands requests to a mounted handler with a decoded path and query', async () => {
  313. const seen: Array<{ pathname: string; symbol: string | null; root: string }> = [];
  314. const withApi = await startUiServer({
  315. projectRoot,
  316. viewerDir,
  317. port: 0,
  318. api: (_req, res, ctx) => {
  319. seen.push({
  320. pathname: ctx.pathname,
  321. symbol: ctx.query.get('symbol'),
  322. root: ctx.projectRoot,
  323. });
  324. res.writeHead(200, { 'Content-Type': 'application/json' });
  325. res.end('{"ok":true}');
  326. return true;
  327. },
  328. });
  329. try {
  330. const res = await request(withApi.port, '/api/node?symbol=parse%20Token');
  331. expect(res.status).toBe(200);
  332. expect(JSON.parse(res.body)).toEqual({ ok: true });
  333. expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]);
  334. } finally {
  335. await withApi.close();
  336. }
  337. });
  338. it('turns a throwing handler into a JSON 500, not a crashed server', async () => {
  339. const withApi = await startUiServer({
  340. projectRoot,
  341. viewerDir,
  342. port: 0,
  343. api: () => {
  344. throw new Error('handler blew up');
  345. },
  346. });
  347. try {
  348. const res = await request(withApi.port, '/api/boom');
  349. expect(res.status).toBe(500);
  350. expect(JSON.parse(res.body).error).toContain('handler blew up');
  351. // Still alive afterwards.
  352. expect((await request(withApi.port, '/')).status).toBe(200);
  353. } finally {
  354. await withApi.close();
  355. }
  356. });
  357. });
  358. });
  359. describe('resolveProjectFile — the source read chokepoint', () => {
  360. let tempDir: string;
  361. let projectRoot: string;
  362. beforeAll(() => {
  363. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-'));
  364. projectRoot = path.join(tempDir, 'project');
  365. fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
  366. fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
  367. fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
  368. });
  369. afterAll(() => {
  370. fs.rmSync(tempDir, { recursive: true, force: true });
  371. });
  372. it('resolves a file inside the project', () => {
  373. expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe(
  374. fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts'))
  375. );
  376. });
  377. it('refuses traversal out of the project', () => {
  378. for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) {
  379. expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError);
  380. }
  381. });
  382. it('refuses an absolute path', () => {
  383. expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow(
  384. PathRefusalError
  385. );
  386. });
  387. it('refuses an empty path', () => {
  388. expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError);
  389. expect(() => resolveProjectFile(projectRoot, ' ')).toThrow(PathRefusalError);
  390. });
  391. it('refuses a NUL byte', () => {
  392. expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError);
  393. });
  394. // `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path
  395. // list only means anything on POSIX.
  396. it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => {
  397. expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError);
  398. expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError);
  399. });
  400. it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => {
  401. const link = path.join(projectRoot, 'src', 'escape.ts');
  402. fs.symlinkSync(path.join(tempDir, 'secret.txt'), link);
  403. try {
  404. expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError);
  405. } finally {
  406. fs.unlinkSync(link);
  407. }
  408. });
  409. });
  410. describe('security helpers', () => {
  411. it('isAllowedHost accepts only loopback names on our port', () => {
  412. expect(isAllowedHost('127.0.0.1', 4747)).toBe(true);
  413. expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true);
  414. expect(isAllowedHost('localhost:4747', 4747)).toBe(true);
  415. expect(isAllowedHost('LOCALHOST', 4747)).toBe(true);
  416. expect(isAllowedHost('[::1]:4747', 4747)).toBe(true);
  417. expect(isAllowedHost(undefined, 4747)).toBe(false);
  418. expect(isAllowedHost('', 4747)).toBe(false);
  419. expect(isAllowedHost('evil.example', 4747)).toBe(false);
  420. expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false);
  421. expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false);
  422. expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false);
  423. expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false);
  424. // Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at.
  425. expect(isAllowedHost('::1', 4747)).toBe(false);
  426. // A non-loopback address that merely resolves here still fails the check.
  427. expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false);
  428. });
  429. it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => {
  430. expect(isAllowedOrigin(undefined, 4747)).toBe(true);
  431. expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true);
  432. expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true);
  433. expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true);
  434. expect(isAllowedOrigin('null', 4747)).toBe(false);
  435. expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false);
  436. expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false);
  437. expect(isAllowedOrigin('file://', 4747)).toBe(false);
  438. expect(isAllowedOrigin('not a url', 4747)).toBe(false);
  439. });
  440. it('isSafeRequestPath rejects a `..` segment however it is spelled', () => {
  441. expect(isSafeRequestPath('/')).toBe(true);
  442. expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true);
  443. expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true);
  444. expect(isSafeRequestPath('/../secret')).toBe(false);
  445. expect(isSafeRequestPath('/a/../../secret')).toBe(false);
  446. expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false);
  447. expect(isSafeRequestPath('/..%2Fsecret')).toBe(false);
  448. expect(isSafeRequestPath('/a%00b')).toBe(false);
  449. expect(isSafeRequestPath('/a\\b')).toBe(false);
  450. expect(isSafeRequestPath('/%zz')).toBe(false);
  451. });
  452. it('resolveStaticAsset returns null for anything that is not a file in the root', () => {
  453. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-'));
  454. try {
  455. fs.mkdirSync(path.join(dir, 'assets'));
  456. fs.writeFileSync(path.join(dir, 'index.html'), 'x');
  457. expect(resolveStaticAsset(dir, '/index.html')).toBe(
  458. fs.realpathSync(path.join(dir, 'index.html'))
  459. );
  460. expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory
  461. expect(resolveStaticAsset(dir, '/missing.js')).toBeNull();
  462. expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull();
  463. } finally {
  464. fs.rmSync(dir, { recursive: true, force: true });
  465. }
  466. });
  467. it('contentTypeFor covers the viewer bundle and defaults safely', () => {
  468. expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8');
  469. expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8');
  470. expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2');
  471. expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream');
  472. });
  473. it('cacheControlFor pins hashed assets and never index.html', () => {
  474. expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable');
  475. expect(cacheControlFor('index.html')).toBe('no-store');
  476. });
  477. });
  478. describe('browserOpenCommand', () => {
  479. it('uses the platform opener', () => {
  480. expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] });
  481. expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] });
  482. expect(browserOpenCommand('http://x', 'win32')).toEqual({
  483. command: 'cmd',
  484. args: ['/c', 'start', '', 'http://x'],
  485. });
  486. });
  487. it('honours the CODEGRAPH_BROWSER override', () => {
  488. expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({
  489. command: 'firefox',
  490. args: ['http://x'],
  491. });
  492. // Windows routes the override through cmd so a `.cmd`/`.bat` shim — which
  493. // CreateProcess cannot launch directly — still works.
  494. expect(browserOpenCommand('http://x', 'win32', 'C:\\tools\\open.cmd')).toEqual({
  495. command: 'cmd',
  496. args: ['/c', 'C:\\tools\\open.cmd', 'http://x'],
  497. });
  498. for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) {
  499. expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
  500. }
  501. });
  502. });