helpers.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import { describe, expect, test } from 'bun:test';
  2. import {
  3. createServer,
  4. type IncomingMessage,
  5. type ServerResponse,
  6. } from 'node:http';
  7. import {
  8. extractResumeSlug,
  9. isValidId,
  10. readJsonBody,
  11. sendHtml,
  12. sendJson,
  13. } from './helpers';
  14. describe('isValidId', () => {
  15. test('accepts alphanumeric IDs', () => {
  16. expect(isValidId('abc123')).toBe(true);
  17. });
  18. test('accepts hyphens and underscores', () => {
  19. expect(isValidId('my-interview_123')).toBe(true);
  20. });
  21. test('rejects empty string', () => {
  22. expect(isValidId('')).toBe(false);
  23. });
  24. test('rejects spaces', () => {
  25. expect(isValidId('has space')).toBe(false);
  26. });
  27. test('rejects special characters', () => {
  28. expect(isValidId('foo@bar!')).toBe(false);
  29. });
  30. test('rejects path traversal attempts', () => {
  31. expect(isValidId('../etc/passwd')).toBe(false);
  32. });
  33. test('rejects IDs over 256 chars', () => {
  34. expect(isValidId('a'.repeat(257))).toBe(false);
  35. });
  36. test('accepts IDs at exactly 256 chars', () => {
  37. expect(isValidId('a'.repeat(256))).toBe(true);
  38. });
  39. });
  40. describe('extractResumeSlug', () => {
  41. test('strips "recovered-" prefix', () => {
  42. expect(extractResumeSlug('recovered-my-interview')).toBe('my-interview');
  43. });
  44. test('strips prefix from recovered IDs with longer names', () => {
  45. expect(extractResumeSlug('recovered-task-manager')).toBe('task-manager');
  46. });
  47. test('strips hash prefix from standard interview IDs', () => {
  48. // Standard format: abc123def-session-name → splits on first 2 dash-segments
  49. const result = extractResumeSlug('abc123-my-interview');
  50. expect(result).toBe('interview');
  51. });
  52. test('returns full ID when no prefix to strip', () => {
  53. // Short IDs that don't match the hash-prefix pattern
  54. expect(extractResumeSlug('simple')).toBe('simple');
  55. });
  56. test('handles recovered prefix with complex slug', () => {
  57. expect(extractResumeSlug('recovered-some-complex-name')).toBe(
  58. 'some-complex-name',
  59. );
  60. });
  61. });
  62. describe('sendJson', () => {
  63. test('sends JSON response with correct status and content type', async () => {
  64. const server = createServer(
  65. (_request: IncomingMessage, response: ServerResponse) => {
  66. sendJson(response, 200, { status: 'ok' });
  67. },
  68. );
  69. const port = await new Promise<number>((resolve, reject) => {
  70. server.listen(0, '127.0.0.1', () => {
  71. const addr = server.address();
  72. if (!addr || typeof addr === 'string') {
  73. reject(new Error('Failed to bind'));
  74. return;
  75. }
  76. resolve(addr.port);
  77. });
  78. server.on('error', reject);
  79. });
  80. try {
  81. const res = await fetch(`http://127.0.0.1:${port}/`);
  82. expect(res.status).toBe(200);
  83. expect(res.headers.get('content-type')).toBe(
  84. 'application/json; charset=utf-8',
  85. );
  86. const data = (await res.json()) as { status: string };
  87. expect(data.status).toBe('ok');
  88. } finally {
  89. server.close();
  90. }
  91. });
  92. test('sends error status code', async () => {
  93. const server = createServer(
  94. (_request: IncomingMessage, response: ServerResponse) => {
  95. sendJson(response, 404, { error: 'Not found' });
  96. },
  97. );
  98. const port = await new Promise<number>((resolve, reject) => {
  99. server.listen(0, '127.0.0.1', () => {
  100. const addr = server.address();
  101. if (!addr || typeof addr === 'string') {
  102. reject(new Error('Failed to bind'));
  103. return;
  104. }
  105. resolve(addr.port);
  106. });
  107. server.on('error', reject);
  108. });
  109. try {
  110. const res = await fetch(`http://127.0.0.1:${port}/`);
  111. expect(res.status).toBe(404);
  112. const data = (await res.json()) as { error: string };
  113. expect(data.error).toBe('Not found');
  114. } finally {
  115. server.close();
  116. }
  117. });
  118. test('JSON body ends with newline', async () => {
  119. const server = createServer(
  120. (_request: IncomingMessage, response: ServerResponse) => {
  121. sendJson(response, 200, { key: 'value' });
  122. },
  123. );
  124. const port = await new Promise<number>((resolve, reject) => {
  125. server.listen(0, '127.0.0.1', () => {
  126. const addr = server.address();
  127. if (!addr || typeof addr === 'string') {
  128. reject(new Error('Failed to bind'));
  129. return;
  130. }
  131. resolve(addr.port);
  132. });
  133. server.on('error', reject);
  134. });
  135. try {
  136. const res = await fetch(`http://127.0.0.1:${port}/`);
  137. const text = await res.text();
  138. expect(text.endsWith('\n')).toBe(true);
  139. } finally {
  140. server.close();
  141. }
  142. });
  143. });
  144. describe('sendHtml', () => {
  145. test('sends HTML with correct content type', async () => {
  146. const html = '<html><body>Hello</body></html>';
  147. const server = createServer(
  148. (_request: IncomingMessage, response: ServerResponse) => {
  149. sendHtml(response, html);
  150. },
  151. );
  152. const port = await new Promise<number>((resolve, reject) => {
  153. server.listen(0, '127.0.0.1', () => {
  154. const addr = server.address();
  155. if (!addr || typeof addr === 'string') {
  156. reject(new Error('Failed to bind'));
  157. return;
  158. }
  159. resolve(addr.port);
  160. });
  161. server.on('error', reject);
  162. });
  163. try {
  164. const res = await fetch(`http://127.0.0.1:${port}/`);
  165. expect(res.status).toBe(200);
  166. expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
  167. expect(await res.text()).toBe(html);
  168. } finally {
  169. server.close();
  170. }
  171. });
  172. });
  173. describe('readJsonBody', () => {
  174. test('parses valid JSON body', async () => {
  175. const server = createServer(
  176. async (request: IncomingMessage, response: ServerResponse) => {
  177. const body = await readJsonBody(request);
  178. sendJson(response, 200, body);
  179. },
  180. );
  181. const port = await new Promise<number>((resolve, reject) => {
  182. server.listen(0, '127.0.0.1', () => {
  183. const addr = server.address();
  184. if (!addr || typeof addr === 'string') {
  185. reject(new Error('Failed to bind'));
  186. return;
  187. }
  188. resolve(addr.port);
  189. });
  190. server.on('error', reject);
  191. });
  192. try {
  193. const payload = { name: 'test', count: 42 };
  194. const res = await fetch(`http://127.0.0.1:${port}/`, {
  195. method: 'POST',
  196. headers: { 'content-type': 'application/json' },
  197. body: JSON.stringify(payload),
  198. });
  199. expect(res.status).toBe(200);
  200. const data = (await res.json()) as typeof payload;
  201. expect(data).toEqual(payload);
  202. } finally {
  203. server.close();
  204. }
  205. });
  206. test('returns empty object for empty body', async () => {
  207. const server = createServer(
  208. async (request: IncomingMessage, response: ServerResponse) => {
  209. const body = await readJsonBody(request);
  210. sendJson(response, 200, { received: body });
  211. },
  212. );
  213. const port = await new Promise<number>((resolve, reject) => {
  214. server.listen(0, '127.0.0.1', () => {
  215. const addr = server.address();
  216. if (!addr || typeof addr === 'string') {
  217. reject(new Error('Failed to bind'));
  218. return;
  219. }
  220. resolve(addr.port);
  221. });
  222. server.on('error', reject);
  223. });
  224. try {
  225. const res = await fetch(`http://127.0.0.1:${port}/`, {
  226. method: 'POST',
  227. });
  228. const data = (await res.json()) as { received: unknown };
  229. expect(data.received).toEqual({});
  230. } finally {
  231. server.close();
  232. }
  233. });
  234. test('rejects body exceeding 64KB size limit', async () => {
  235. // readJsonBody destroys the request on overflow, so we test the
  236. // error path directly rather than via HTTP (the socket dies before
  237. // a response can be sent).
  238. let caughtError: Error | null = null;
  239. const server = createServer(
  240. async (request: IncomingMessage, response: ServerResponse) => {
  241. try {
  242. await readJsonBody(request);
  243. sendJson(response, 200, { ok: true });
  244. } catch (error) {
  245. caughtError =
  246. error instanceof Error ? error : new Error(String(error));
  247. // Socket already destroyed — can't send response
  248. }
  249. },
  250. );
  251. const port = await new Promise<number>((resolve, reject) => {
  252. server.listen(0, '127.0.0.1', () => {
  253. const addr = server.address();
  254. if (!addr || typeof addr === 'string') {
  255. reject(new Error('Failed to bind'));
  256. return;
  257. }
  258. resolve(addr.port);
  259. });
  260. server.on('error', reject);
  261. });
  262. try {
  263. // Send a body larger than 64KB — connection will reset
  264. const bigPayload = { data: 'x'.repeat(65 * 1024) };
  265. try {
  266. await fetch(`http://127.0.0.1:${port}/`, {
  267. method: 'POST',
  268. headers: { 'content-type': 'application/json' },
  269. body: JSON.stringify(bigPayload),
  270. });
  271. } catch {
  272. // Expected: connection reset when request is destroyed
  273. }
  274. // Give the server a tick to finish processing
  275. await new Promise((resolve) => setTimeout(resolve, 50));
  276. expect(caughtError).not.toBeNull();
  277. expect(caughtError?.message).toContain('too large');
  278. } finally {
  279. server.close();
  280. }
  281. });
  282. test('parses array JSON body', async () => {
  283. const server = createServer(
  284. async (request: IncomingMessage, response: ServerResponse) => {
  285. const body = await readJsonBody(request);
  286. sendJson(response, 200, {
  287. type: Array.isArray(body) ? 'array' : 'other',
  288. });
  289. },
  290. );
  291. const port = await new Promise<number>((resolve, reject) => {
  292. server.listen(0, '127.0.0.1', () => {
  293. const addr = server.address();
  294. if (!addr || typeof addr === 'string') {
  295. reject(new Error('Failed to bind'));
  296. return;
  297. }
  298. resolve(addr.port);
  299. });
  300. server.on('error', reject);
  301. });
  302. try {
  303. const res = await fetch(`http://127.0.0.1:${port}/`, {
  304. method: 'POST',
  305. headers: { 'content-type': 'application/json' },
  306. body: JSON.stringify([1, 2, 3]),
  307. });
  308. const data = (await res.json()) as { type: string };
  309. expect(data.type).toBe('array');
  310. } finally {
  311. server.close();
  312. }
  313. });
  314. });