path.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import { createPathTagFunction, encodeURIPath } from '@opencode-ai/sdk/internal/utils/path';
  2. import { inspect } from 'node:util';
  3. import { runInNewContext } from 'node:vm';
  4. describe('path template tag function', () => {
  5. test('validates input', () => {
  6. const testParams = ['', '.', '..', 'x', '%2e', '%2E', '%2e%2e', '%2E%2e', '%2e%2E', '%2E%2E'];
  7. const testCases = [
  8. ['/path_params/', '/a'],
  9. ['/path_params/', '/'],
  10. ['/path_params/', ''],
  11. ['', '/a'],
  12. ['', '/'],
  13. ['', ''],
  14. ['a'],
  15. [''],
  16. ['/path_params/', ':initiate'],
  17. ['/path_params/', '.json'],
  18. ['/path_params/', '?beta=true'],
  19. ['/path_params/', '.?beta=true'],
  20. ['/path_params/', '/', '/download'],
  21. ['/path_params/', '-', '/download'],
  22. ['/path_params/', '', '/download'],
  23. ['/path_params/', '.', '/download'],
  24. ['/path_params/', '..', '/download'],
  25. ['/plain/path'],
  26. ];
  27. function paramPermutations(len: number): string[][] {
  28. if (len === 0) return [];
  29. if (len === 1) return testParams.map((e) => [e]);
  30. const rest = paramPermutations(len - 1);
  31. return testParams.flatMap((e) => rest.map((r) => [e, ...r]));
  32. }
  33. // We need to test how %2E is handled, so we use a custom encoder that does no escaping.
  34. const rawPath = createPathTagFunction((s) => s);
  35. const emptyObject = {};
  36. const mathObject = Math;
  37. const numberObject = new Number();
  38. const stringObject = new String();
  39. const basicClass = new (class {})();
  40. const classWithToString = new (class {
  41. toString() {
  42. return 'ok';
  43. }
  44. })();
  45. // Invalid values
  46. expect(() => rawPath`/a/${null}/b`).toThrow(
  47. 'Path parameters result in path with invalid segments:\n' +
  48. 'Value of type Null is not a valid path parameter\n' +
  49. '/a/null/b\n' +
  50. ' ^^^^',
  51. );
  52. expect(() => rawPath`/a/${undefined}/b`).toThrow(
  53. 'Path parameters result in path with invalid segments:\n' +
  54. 'Value of type Undefined is not a valid path parameter\n' +
  55. '/a/undefined/b\n' +
  56. ' ^^^^^^^^^',
  57. );
  58. expect(() => rawPath`/a/${emptyObject}/b`).toThrow(
  59. 'Path parameters result in path with invalid segments:\n' +
  60. 'Value of type Object is not a valid path parameter\n' +
  61. '/a/[object Object]/b\n' +
  62. ' ^^^^^^^^^^^^^^^',
  63. );
  64. expect(() => rawPath`?${mathObject}`).toThrow(
  65. 'Path parameters result in path with invalid segments:\n' +
  66. 'Value of type Math is not a valid path parameter\n' +
  67. '?[object Math]\n' +
  68. ' ^^^^^^^^^^^^^',
  69. );
  70. expect(() => rawPath`/${basicClass}`).toThrow(
  71. 'Path parameters result in path with invalid segments:\n' +
  72. 'Value of type Object is not a valid path parameter\n' +
  73. '/[object Object]\n' +
  74. ' ^^^^^^^^^^^^^^',
  75. );
  76. expect(() => rawPath`/../${''}`).toThrow(
  77. 'Path parameters result in path with invalid segments:\n' +
  78. 'Value ".." can\'t be safely passed as a path parameter\n' +
  79. '/../\n' +
  80. ' ^^',
  81. );
  82. expect(() => rawPath`/../${{}}`).toThrow(
  83. 'Path parameters result in path with invalid segments:\n' +
  84. 'Value ".." can\'t be safely passed as a path parameter\n' +
  85. 'Value of type Object is not a valid path parameter\n' +
  86. '/../[object Object]\n' +
  87. ' ^^ ^^^^^^^^^^^^^^',
  88. );
  89. // Valid values
  90. expect(rawPath`/${0}`).toBe('/0');
  91. expect(rawPath`/${''}`).toBe('/');
  92. expect(rawPath`/${numberObject}`).toBe('/0');
  93. expect(rawPath`${stringObject}/`).toBe('/');
  94. expect(rawPath`/${classWithToString}`).toBe('/ok');
  95. // We need to check what happens with cross-realm values, which we might get from
  96. // Jest or other frames in a browser.
  97. const newRealm = runInNewContext('globalThis');
  98. expect(newRealm.Object).not.toBe(Object);
  99. const crossRealmObject = newRealm.Object();
  100. const crossRealmMathObject = newRealm.Math;
  101. const crossRealmNumber = new newRealm.Number();
  102. const crossRealmString = new newRealm.String();
  103. const crossRealmClass = new (class extends newRealm.Object {})();
  104. const crossRealmClassWithToString = new (class extends newRealm.Object {
  105. toString() {
  106. return 'ok';
  107. }
  108. })();
  109. // Invalid cross-realm values
  110. expect(() => rawPath`/a/${crossRealmObject}/b`).toThrow(
  111. 'Path parameters result in path with invalid segments:\n' +
  112. 'Value of type Object is not a valid path parameter\n' +
  113. '/a/[object Object]/b\n' +
  114. ' ^^^^^^^^^^^^^^^',
  115. );
  116. expect(() => rawPath`?${crossRealmMathObject}`).toThrow(
  117. 'Path parameters result in path with invalid segments:\n' +
  118. 'Value of type Math is not a valid path parameter\n' +
  119. '?[object Math]\n' +
  120. ' ^^^^^^^^^^^^^',
  121. );
  122. expect(() => rawPath`/${crossRealmClass}`).toThrow(
  123. 'Path parameters result in path with invalid segments:\n' +
  124. 'Value of type Object is not a valid path parameter\n' +
  125. '/[object Object]\n' +
  126. ' ^^^^^^^^^^^^^^^',
  127. );
  128. // Valid cross-realm values
  129. expect(rawPath`/${crossRealmNumber}`).toBe('/0');
  130. expect(rawPath`${crossRealmString}/`).toBe('/');
  131. expect(rawPath`/${crossRealmClassWithToString}`).toBe('/ok');
  132. const results: {
  133. [pathParts: string]: {
  134. [params: string]: { valid: boolean; result?: string; error?: string };
  135. };
  136. } = {};
  137. for (const pathParts of testCases) {
  138. const pathResults: Record<string, { valid: boolean; result?: string; error?: string }> = {};
  139. results[JSON.stringify(pathParts)] = pathResults;
  140. for (const params of paramPermutations(pathParts.length - 1)) {
  141. const stringRaw = String.raw({ raw: pathParts }, ...params);
  142. const plainString = String.raw(
  143. { raw: pathParts.map((e) => e.replace(/\./g, 'x')) },
  144. ...params.map((e) => 'X'.repeat(e.length)),
  145. );
  146. const normalizedStringRaw = new URL(stringRaw, 'https://example.com').href;
  147. const normalizedPlainString = new URL(plainString, 'https://example.com').href;
  148. const pathResultsKey = JSON.stringify(params);
  149. try {
  150. const result = rawPath(pathParts, ...params);
  151. expect(result).toBe(stringRaw);
  152. // there are no special segments, so the length of the normalized path is
  153. // equal to the length of the normalized plain path.
  154. expect(normalizedStringRaw.length).toBe(normalizedPlainString.length);
  155. pathResults[pathResultsKey] = {
  156. valid: true,
  157. result,
  158. };
  159. } catch (e) {
  160. const error = String(e);
  161. expect(error).toMatch(/Path parameters result in path with invalid segment/);
  162. // there are special segments, so the length of the normalized path is
  163. // different than the length of the normalized plain path.
  164. expect(normalizedStringRaw.length).not.toBe(normalizedPlainString.length);
  165. pathResults[pathResultsKey] = {
  166. valid: false,
  167. error,
  168. };
  169. }
  170. }
  171. }
  172. expect(results).toMatchObject({
  173. '["/path_params/","/a"]': {
  174. '["x"]': { valid: true, result: '/path_params/x/a' },
  175. '[""]': { valid: true, result: '/path_params//a' },
  176. '["%2E%2e"]': {
  177. valid: false,
  178. error:
  179. 'Error: Path parameters result in path with invalid segments:\n' +
  180. 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' +
  181. '/path_params/%2E%2e/a\n' +
  182. ' ^^^^^^',
  183. },
  184. '["%2E"]': {
  185. valid: false,
  186. error:
  187. 'Error: Path parameters result in path with invalid segments:\n' +
  188. 'Value "%2E" can\'t be safely passed as a path parameter\n' +
  189. '/path_params/%2E/a\n' +
  190. ' ^^^',
  191. },
  192. },
  193. '["/path_params/","/"]': {
  194. '["x"]': { valid: true, result: '/path_params/x/' },
  195. '[""]': { valid: true, result: '/path_params//' },
  196. '["%2e%2E"]': {
  197. valid: false,
  198. error:
  199. 'Error: Path parameters result in path with invalid segments:\n' +
  200. 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' +
  201. '/path_params/%2e%2E/\n' +
  202. ' ^^^^^^',
  203. },
  204. '["%2e"]': {
  205. valid: false,
  206. error:
  207. 'Error: Path parameters result in path with invalid segments:\n' +
  208. 'Value "%2e" can\'t be safely passed as a path parameter\n' +
  209. '/path_params/%2e/\n' +
  210. ' ^^^',
  211. },
  212. },
  213. '["/path_params/",""]': {
  214. '[""]': { valid: true, result: '/path_params/' },
  215. '["x"]': { valid: true, result: '/path_params/x' },
  216. '["%2E"]': {
  217. valid: false,
  218. error:
  219. 'Error: Path parameters result in path with invalid segments:\n' +
  220. 'Value "%2E" can\'t be safely passed as a path parameter\n' +
  221. '/path_params/%2E\n' +
  222. ' ^^^',
  223. },
  224. '["%2E%2e"]': {
  225. valid: false,
  226. error:
  227. 'Error: Path parameters result in path with invalid segments:\n' +
  228. 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' +
  229. '/path_params/%2E%2e\n' +
  230. ' ^^^^^^',
  231. },
  232. },
  233. '["","/a"]': {
  234. '[""]': { valid: true, result: '/a' },
  235. '["x"]': { valid: true, result: 'x/a' },
  236. '["%2E"]': {
  237. valid: false,
  238. error:
  239. 'Error: Path parameters result in path with invalid segments:\n' +
  240. 'Value "%2E" can\'t be safely passed as a path parameter\n%2E/a\n^^^',
  241. },
  242. '["%2e%2E"]': {
  243. valid: false,
  244. error:
  245. 'Error: Path parameters result in path with invalid segments:\n' +
  246. 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' +
  247. '%2e%2E/a\n' +
  248. '^^^^^^',
  249. },
  250. },
  251. '["","/"]': {
  252. '["x"]': { valid: true, result: 'x/' },
  253. '[""]': { valid: true, result: '/' },
  254. '["%2E%2e"]': {
  255. valid: false,
  256. error:
  257. 'Error: Path parameters result in path with invalid segments:\n' +
  258. 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' +
  259. '%2E%2e/\n' +
  260. '^^^^^^',
  261. },
  262. '["."]': {
  263. valid: false,
  264. error:
  265. 'Error: Path parameters result in path with invalid segments:\n' +
  266. 'Value "." can\'t be safely passed as a path parameter\n' +
  267. './\n^',
  268. },
  269. },
  270. '["",""]': {
  271. '[""]': { valid: true, result: '' },
  272. '["x"]': { valid: true, result: 'x' },
  273. '[".."]': {
  274. valid: false,
  275. error:
  276. 'Error: Path parameters result in path with invalid segments:\n' +
  277. 'Value ".." can\'t be safely passed as a path parameter\n' +
  278. '..\n^^',
  279. },
  280. '["."]': {
  281. valid: false,
  282. error:
  283. 'Error: Path parameters result in path with invalid segments:\n' +
  284. 'Value "." can\'t be safely passed as a path parameter\n' +
  285. '.\n^',
  286. },
  287. },
  288. '["a"]': {},
  289. '[""]': {},
  290. '["/path_params/",":initiate"]': {
  291. '[""]': { valid: true, result: '/path_params/:initiate' },
  292. '["."]': { valid: true, result: '/path_params/.:initiate' },
  293. },
  294. '["/path_params/",".json"]': {
  295. '["x"]': { valid: true, result: '/path_params/x.json' },
  296. '["."]': { valid: true, result: '/path_params/..json' },
  297. },
  298. '["/path_params/","?beta=true"]': {
  299. '["x"]': { valid: true, result: '/path_params/x?beta=true' },
  300. '[""]': { valid: true, result: '/path_params/?beta=true' },
  301. '["%2E%2E"]': {
  302. valid: false,
  303. error:
  304. 'Error: Path parameters result in path with invalid segments:\n' +
  305. 'Value "%2E%2E" can\'t be safely passed as a path parameter\n' +
  306. '/path_params/%2E%2E?beta=true\n' +
  307. ' ^^^^^^',
  308. },
  309. '["%2e%2E"]': {
  310. valid: false,
  311. error:
  312. 'Error: Path parameters result in path with invalid segments:\n' +
  313. 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' +
  314. '/path_params/%2e%2E?beta=true\n' +
  315. ' ^^^^^^',
  316. },
  317. },
  318. '["/path_params/",".?beta=true"]': {
  319. '[".."]': { valid: true, result: '/path_params/...?beta=true' },
  320. '["x"]': { valid: true, result: '/path_params/x.?beta=true' },
  321. '[""]': {
  322. valid: false,
  323. error:
  324. 'Error: Path parameters result in path with invalid segments:\n' +
  325. 'Value "." can\'t be safely passed as a path parameter\n' +
  326. '/path_params/.?beta=true\n' +
  327. ' ^',
  328. },
  329. '["%2e"]': {
  330. valid: false,
  331. error:
  332. 'Error: Path parameters result in path with invalid segments:\n' +
  333. 'Value "%2e." can\'t be safely passed as a path parameter\n' +
  334. '/path_params/%2e.?beta=true\n' +
  335. ' ^^^^',
  336. },
  337. },
  338. '["/path_params/","/","/download"]': {
  339. '["",""]': { valid: true, result: '/path_params///download' },
  340. '["","x"]': { valid: true, result: '/path_params//x/download' },
  341. '[".","%2e"]': {
  342. valid: false,
  343. error:
  344. 'Error: Path parameters result in path with invalid segments:\n' +
  345. 'Value "." can\'t be safely passed as a path parameter\n' +
  346. 'Value "%2e" can\'t be safely passed as a path parameter\n' +
  347. '/path_params/./%2e/download\n' +
  348. ' ^ ^^^',
  349. },
  350. '["%2E%2e","%2e"]': {
  351. valid: false,
  352. error:
  353. 'Error: Path parameters result in path with invalid segments:\n' +
  354. 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' +
  355. 'Value "%2e" can\'t be safely passed as a path parameter\n' +
  356. '/path_params/%2E%2e/%2e/download\n' +
  357. ' ^^^^^^ ^^^',
  358. },
  359. },
  360. '["/path_params/","-","/download"]': {
  361. '["","%2e"]': { valid: true, result: '/path_params/-%2e/download' },
  362. '["%2E",".."]': { valid: true, result: '/path_params/%2E-../download' },
  363. },
  364. '["/path_params/","","/download"]': {
  365. '["%2E%2e","%2e%2E"]': { valid: true, result: '/path_params/%2E%2e%2e%2E/download' },
  366. '["%2E",".."]': { valid: true, result: '/path_params/%2E../download' },
  367. '["","%2E"]': {
  368. valid: false,
  369. error:
  370. 'Error: Path parameters result in path with invalid segments:\n' +
  371. 'Value "%2E" can\'t be safely passed as a path parameter\n' +
  372. '/path_params/%2E/download\n' +
  373. ' ^^^',
  374. },
  375. '["%2E","."]': {
  376. valid: false,
  377. error:
  378. 'Error: Path parameters result in path with invalid segments:\n' +
  379. 'Value "%2E." can\'t be safely passed as a path parameter\n' +
  380. '/path_params/%2E./download\n' +
  381. ' ^^^^',
  382. },
  383. },
  384. '["/path_params/",".","/download"]': {
  385. '["%2e%2e",""]': { valid: true, result: '/path_params/%2e%2e./download' },
  386. '["","%2e%2e"]': { valid: true, result: '/path_params/.%2e%2e/download' },
  387. '["",""]': {
  388. valid: false,
  389. error:
  390. 'Error: Path parameters result in path with invalid segments:\n' +
  391. 'Value "." can\'t be safely passed as a path parameter\n' +
  392. '/path_params/./download\n' +
  393. ' ^',
  394. },
  395. '["","."]': {
  396. valid: false,
  397. error:
  398. 'Error: Path parameters result in path with invalid segments:\n' +
  399. 'Value ".." can\'t be safely passed as a path parameter\n' +
  400. '/path_params/../download\n' +
  401. ' ^^',
  402. },
  403. },
  404. '["/path_params/","..","/download"]': {
  405. '["","%2E"]': { valid: true, result: '/path_params/..%2E/download' },
  406. '["","x"]': { valid: true, result: '/path_params/..x/download' },
  407. '["",""]': {
  408. valid: false,
  409. error:
  410. 'Error: Path parameters result in path with invalid segments:\n' +
  411. 'Value ".." can\'t be safely passed as a path parameter\n' +
  412. '/path_params/../download\n' +
  413. ' ^^',
  414. },
  415. },
  416. });
  417. });
  418. });
  419. describe('encodeURIPath', () => {
  420. const testCases: string[] = [
  421. '',
  422. // Every ASCII character
  423. ...Array.from({ length: 0x7f }, (_, i) => String.fromCharCode(i)),
  424. // Unicode BMP codepoint
  425. 'å',
  426. // Unicode supplementary codepoint
  427. '😃',
  428. ];
  429. for (const param of testCases) {
  430. test('properly encodes ' + inspect(param), () => {
  431. const encoded = encodeURIPath(param);
  432. const naiveEncoded = encodeURIComponent(param);
  433. // we should never encode more characters than encodeURIComponent
  434. expect(naiveEncoded.length).toBeGreaterThanOrEqual(encoded.length);
  435. expect(decodeURIComponent(encoded)).toBe(param);
  436. });
  437. }
  438. test("leaves ':' intact", () => {
  439. expect(encodeURIPath(':')).toBe(':');
  440. });
  441. test("leaves '@' intact", () => {
  442. expect(encodeURIPath('@')).toBe('@');
  443. });
  444. });