ws-protocol.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. /**
  2. * Unit tests for the zero-dependency WebSocket protocol implementation.
  3. *
  4. * Tests the WebSocket frame encoding/decoding, handshake computation,
  5. * and protocol-level behavior independent of the HTTP server.
  6. *
  7. * The module under test exports:
  8. * - computeAcceptKey(clientKey) -> string
  9. * - encodeFrame(opcode, payload) -> Buffer
  10. * - decodeFrame(buffer) -> { opcode, payload, bytesConsumed } | null
  11. * - OPCODES: { TEXT, CLOSE, PING, PONG }
  12. */
  13. const assert = require('assert');
  14. const crypto = require('crypto');
  15. const path = require('path');
  16. // The module under test — will be the new zero-dep server file
  17. const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.cjs');
  18. let ws;
  19. try {
  20. ws = require(SERVER_PATH);
  21. } catch (e) {
  22. // Module doesn't exist yet (TDD — tests written before implementation)
  23. console.error(`Cannot load ${SERVER_PATH}: ${e.message}`);
  24. console.error('This is expected if running tests before implementation.');
  25. process.exit(1);
  26. }
  27. function runTests() {
  28. let passed = 0;
  29. let failed = 0;
  30. function test(name, fn) {
  31. try {
  32. fn();
  33. console.log(` PASS: ${name}`);
  34. passed++;
  35. } catch (e) {
  36. console.log(` FAIL: ${name}`);
  37. console.log(` ${e.message}`);
  38. failed++;
  39. }
  40. }
  41. // ========== Handshake ==========
  42. console.log('\n--- WebSocket Handshake ---');
  43. test('computeAcceptKey produces correct RFC 6455 accept value', () => {
  44. // RFC 6455 Section 4.2.2 example
  45. // The magic GUID is "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
  46. const clientKey = 'dGhlIHNhbXBsZSBub25jZQ==';
  47. const expected = 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=';
  48. assert.strictEqual(ws.computeAcceptKey(clientKey), expected);
  49. });
  50. test('computeAcceptKey produces valid base64 for random keys', () => {
  51. for (let i = 0; i < 10; i++) {
  52. const randomKey = crypto.randomBytes(16).toString('base64');
  53. const result = ws.computeAcceptKey(randomKey);
  54. // Result should be valid base64
  55. assert.strictEqual(Buffer.from(result, 'base64').toString('base64'), result);
  56. // SHA-1 output is 20 bytes, base64 encoded = 28 chars
  57. assert.strictEqual(result.length, 28);
  58. }
  59. });
  60. // ========== Frame Encoding ==========
  61. console.log('\n--- Frame Encoding (server -> client) ---');
  62. test('encodes small text frame (< 126 bytes)', () => {
  63. const payload = 'Hello';
  64. const frame = ws.encodeFrame(ws.OPCODES.TEXT, Buffer.from(payload));
  65. // FIN bit + TEXT opcode = 0x81, length = 5
  66. assert.strictEqual(frame[0], 0x81);
  67. assert.strictEqual(frame[1], 5);
  68. assert.strictEqual(frame.slice(2).toString(), 'Hello');
  69. assert.strictEqual(frame.length, 7);
  70. });
  71. test('encodes empty text frame', () => {
  72. const frame = ws.encodeFrame(ws.OPCODES.TEXT, Buffer.alloc(0));
  73. assert.strictEqual(frame[0], 0x81);
  74. assert.strictEqual(frame[1], 0);
  75. assert.strictEqual(frame.length, 2);
  76. });
  77. test('encodes medium text frame (126-65535 bytes)', () => {
  78. const payload = Buffer.alloc(200, 0x41); // 200 'A's
  79. const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
  80. assert.strictEqual(frame[0], 0x81);
  81. assert.strictEqual(frame[1], 126); // extended length marker
  82. assert.strictEqual(frame.readUInt16BE(2), 200);
  83. assert.strictEqual(frame.slice(4).toString(), payload.toString());
  84. assert.strictEqual(frame.length, 204);
  85. });
  86. test('encodes frame at exactly 126 bytes (boundary)', () => {
  87. const payload = Buffer.alloc(126, 0x42);
  88. const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
  89. assert.strictEqual(frame[1], 126); // extended length marker
  90. assert.strictEqual(frame.readUInt16BE(2), 126);
  91. assert.strictEqual(frame.length, 130);
  92. });
  93. test('encodes frame at exactly 125 bytes (max small)', () => {
  94. const payload = Buffer.alloc(125, 0x43);
  95. const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
  96. assert.strictEqual(frame[1], 125);
  97. assert.strictEqual(frame.length, 127);
  98. });
  99. test('encodes large frame (> 65535 bytes)', () => {
  100. const payload = Buffer.alloc(70000, 0x44);
  101. const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
  102. assert.strictEqual(frame[0], 0x81);
  103. assert.strictEqual(frame[1], 127); // 64-bit length marker
  104. // 8-byte extended length at offset 2
  105. const len = Number(frame.readBigUInt64BE(2));
  106. assert.strictEqual(len, 70000);
  107. assert.strictEqual(frame.length, 10 + 70000);
  108. });
  109. test('encodes close frame', () => {
  110. const frame = ws.encodeFrame(ws.OPCODES.CLOSE, Buffer.alloc(0));
  111. assert.strictEqual(frame[0], 0x88); // FIN + CLOSE
  112. assert.strictEqual(frame[1], 0);
  113. });
  114. test('encodes pong frame with payload', () => {
  115. const payload = Buffer.from('ping-data');
  116. const frame = ws.encodeFrame(ws.OPCODES.PONG, payload);
  117. assert.strictEqual(frame[0], 0x8A); // FIN + PONG
  118. assert.strictEqual(frame[1], payload.length);
  119. assert.strictEqual(frame.slice(2).toString(), 'ping-data');
  120. });
  121. test('server frames are never masked (per RFC 6455)', () => {
  122. const frame = ws.encodeFrame(ws.OPCODES.TEXT, Buffer.from('test'));
  123. // Bit 7 of byte 1 is the mask bit — must be 0 for server frames
  124. assert.strictEqual(frame[1] & 0x80, 0);
  125. });
  126. // ========== Frame Decoding ==========
  127. console.log('\n--- Frame Decoding (client -> server) ---');
  128. // Helper: create a masked client frame
  129. function makeClientFrame(opcode, payload, fin = true) {
  130. const buf = Buffer.from(payload);
  131. const mask = crypto.randomBytes(4);
  132. const masked = Buffer.alloc(buf.length);
  133. for (let i = 0; i < buf.length; i++) {
  134. masked[i] = buf[i] ^ mask[i % 4];
  135. }
  136. let header;
  137. const finBit = fin ? 0x80 : 0x00;
  138. if (buf.length < 126) {
  139. header = Buffer.alloc(6);
  140. header[0] = finBit | opcode;
  141. header[1] = 0x80 | buf.length; // mask bit set
  142. mask.copy(header, 2);
  143. } else if (buf.length < 65536) {
  144. header = Buffer.alloc(8);
  145. header[0] = finBit | opcode;
  146. header[1] = 0x80 | 126;
  147. header.writeUInt16BE(buf.length, 2);
  148. mask.copy(header, 4);
  149. } else {
  150. header = Buffer.alloc(14);
  151. header[0] = finBit | opcode;
  152. header[1] = 0x80 | 127;
  153. header.writeBigUInt64BE(BigInt(buf.length), 2);
  154. mask.copy(header, 10);
  155. }
  156. return Buffer.concat([header, masked]);
  157. }
  158. test('decodes small masked text frame', () => {
  159. const frame = makeClientFrame(0x01, 'Hello');
  160. const result = ws.decodeFrame(frame);
  161. assert(result, 'Should return a result');
  162. assert.strictEqual(result.opcode, ws.OPCODES.TEXT);
  163. assert.strictEqual(result.payload.toString(), 'Hello');
  164. assert.strictEqual(result.bytesConsumed, frame.length);
  165. });
  166. test('decodes empty masked text frame', () => {
  167. const frame = makeClientFrame(0x01, '');
  168. const result = ws.decodeFrame(frame);
  169. assert(result, 'Should return a result');
  170. assert.strictEqual(result.opcode, ws.OPCODES.TEXT);
  171. assert.strictEqual(result.payload.length, 0);
  172. });
  173. test('decodes medium masked text frame (126-65535 bytes)', () => {
  174. const payload = 'A'.repeat(200);
  175. const frame = makeClientFrame(0x01, payload);
  176. const result = ws.decodeFrame(frame);
  177. assert(result, 'Should return a result');
  178. assert.strictEqual(result.payload.toString(), payload);
  179. });
  180. test('decodes large masked text frame (> 65535 bytes)', () => {
  181. const payload = 'B'.repeat(70000);
  182. const frame = makeClientFrame(0x01, payload);
  183. const result = ws.decodeFrame(frame);
  184. assert(result, 'Should return a result');
  185. assert.strictEqual(result.payload.length, 70000);
  186. assert.strictEqual(result.payload.toString(), payload);
  187. });
  188. test('decodes masked close frame', () => {
  189. const frame = makeClientFrame(0x08, '');
  190. const result = ws.decodeFrame(frame);
  191. assert(result, 'Should return a result');
  192. assert.strictEqual(result.opcode, ws.OPCODES.CLOSE);
  193. });
  194. test('decodes masked ping frame', () => {
  195. const frame = makeClientFrame(0x09, 'ping!');
  196. const result = ws.decodeFrame(frame);
  197. assert(result, 'Should return a result');
  198. assert.strictEqual(result.opcode, ws.OPCODES.PING);
  199. assert.strictEqual(result.payload.toString(), 'ping!');
  200. });
  201. test('returns null for incomplete frame (not enough header bytes)', () => {
  202. const result = ws.decodeFrame(Buffer.from([0x81]));
  203. assert.strictEqual(result, null, 'Should return null for 1-byte buffer');
  204. });
  205. test('returns null for incomplete frame (header ok, payload truncated)', () => {
  206. // Create a valid frame then truncate it
  207. const frame = makeClientFrame(0x01, 'Hello World');
  208. const truncated = frame.slice(0, frame.length - 3);
  209. const result = ws.decodeFrame(truncated);
  210. assert.strictEqual(result, null, 'Should return null for truncated frame');
  211. });
  212. test('returns null for incomplete extended-length header', () => {
  213. // Frame claiming 16-bit length but only 3 bytes total
  214. const buf = Buffer.alloc(3);
  215. buf[0] = 0x81;
  216. buf[1] = 0x80 | 126; // masked, 16-bit extended
  217. // Missing the 2 length bytes + mask
  218. const result = ws.decodeFrame(buf);
  219. assert.strictEqual(result, null);
  220. });
  221. test('rejects unmasked client frame', () => {
  222. // Server MUST reject unmasked client frames per RFC 6455 Section 5.1
  223. const buf = Buffer.alloc(7);
  224. buf[0] = 0x81; // FIN + TEXT
  225. buf[1] = 5; // length 5, NO mask bit
  226. Buffer.from('Hello').copy(buf, 2);
  227. assert.throws(() => ws.decodeFrame(buf), /mask/i, 'Should reject unmasked client frame');
  228. });
  229. test('handles multiple frames in a single buffer', () => {
  230. const frame1 = makeClientFrame(0x01, 'first');
  231. const frame2 = makeClientFrame(0x01, 'second');
  232. const combined = Buffer.concat([frame1, frame2]);
  233. const result1 = ws.decodeFrame(combined);
  234. assert(result1, 'Should decode first frame');
  235. assert.strictEqual(result1.payload.toString(), 'first');
  236. assert.strictEqual(result1.bytesConsumed, frame1.length);
  237. const result2 = ws.decodeFrame(combined.slice(result1.bytesConsumed));
  238. assert(result2, 'Should decode second frame');
  239. assert.strictEqual(result2.payload.toString(), 'second');
  240. });
  241. test('correctly unmasks with all mask byte values', () => {
  242. // Use a known mask to verify unmasking arithmetic
  243. const payload = Buffer.from('ABCDEFGH');
  244. const mask = Buffer.from([0xFF, 0x00, 0xAA, 0x55]);
  245. const masked = Buffer.alloc(payload.length);
  246. for (let i = 0; i < payload.length; i++) {
  247. masked[i] = payload[i] ^ mask[i % 4];
  248. }
  249. // Build frame manually
  250. const header = Buffer.alloc(6);
  251. header[0] = 0x81; // FIN + TEXT
  252. header[1] = 0x80 | payload.length;
  253. mask.copy(header, 2);
  254. const frame = Buffer.concat([header, masked]);
  255. const result = ws.decodeFrame(frame);
  256. assert.strictEqual(result.payload.toString(), 'ABCDEFGH');
  257. });
  258. // ========== Frame Encoding Boundary at 65535/65536 ==========
  259. console.log('\n--- Frame Size Boundaries ---');
  260. test('encodes frame at exactly 65535 bytes (max 16-bit)', () => {
  261. const payload = Buffer.alloc(65535, 0x45);
  262. const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
  263. assert.strictEqual(frame[1], 126);
  264. assert.strictEqual(frame.readUInt16BE(2), 65535);
  265. assert.strictEqual(frame.length, 4 + 65535);
  266. });
  267. test('encodes frame at exactly 65536 bytes (min 64-bit)', () => {
  268. const payload = Buffer.alloc(65536, 0x46);
  269. const frame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
  270. assert.strictEqual(frame[1], 127);
  271. assert.strictEqual(Number(frame.readBigUInt64BE(2)), 65536);
  272. assert.strictEqual(frame.length, 10 + 65536);
  273. });
  274. test('decodes frame at 65535 bytes boundary', () => {
  275. const payload = 'X'.repeat(65535);
  276. const frame = makeClientFrame(0x01, payload);
  277. const result = ws.decodeFrame(frame);
  278. assert(result);
  279. assert.strictEqual(result.payload.length, 65535);
  280. });
  281. test('decodes frame at 65536 bytes boundary', () => {
  282. const payload = 'Y'.repeat(65536);
  283. const frame = makeClientFrame(0x01, payload);
  284. const result = ws.decodeFrame(frame);
  285. assert(result);
  286. assert.strictEqual(result.payload.length, 65536);
  287. });
  288. // ========== Close Frame with Status Code ==========
  289. console.log('\n--- Close Frame Details ---');
  290. test('decodes close frame with status code', () => {
  291. // Close frame payload: 2-byte status code + optional reason
  292. const statusBuf = Buffer.alloc(2);
  293. statusBuf.writeUInt16BE(1000); // Normal closure
  294. const frame = makeClientFrame(0x08, statusBuf);
  295. const result = ws.decodeFrame(frame);
  296. assert.strictEqual(result.opcode, ws.OPCODES.CLOSE);
  297. assert.strictEqual(result.payload.readUInt16BE(0), 1000);
  298. });
  299. test('decodes close frame with status code and reason', () => {
  300. const reason = 'Normal shutdown';
  301. const payload = Buffer.alloc(2 + reason.length);
  302. payload.writeUInt16BE(1000);
  303. payload.write(reason, 2);
  304. const frame = makeClientFrame(0x08, payload);
  305. const result = ws.decodeFrame(frame);
  306. assert.strictEqual(result.opcode, ws.OPCODES.CLOSE);
  307. assert.strictEqual(result.payload.slice(2).toString(), reason);
  308. });
  309. // ========== JSON Roundtrip ==========
  310. console.log('\n--- JSON Message Roundtrip ---');
  311. test('roundtrip encode/decode of JSON message', () => {
  312. const msg = { type: 'reload' };
  313. const payload = Buffer.from(JSON.stringify(msg));
  314. const serverFrame = ws.encodeFrame(ws.OPCODES.TEXT, payload);
  315. // Verify we can read what we encoded (unmasked server frame)
  316. // Server frames don't go through decodeFrame (that expects masked),
  317. // so just verify the payload bytes directly
  318. let offset;
  319. if (serverFrame[1] < 126) {
  320. offset = 2;
  321. } else if (serverFrame[1] === 126) {
  322. offset = 4;
  323. } else {
  324. offset = 10;
  325. }
  326. const decoded = JSON.parse(serverFrame.slice(offset).toString());
  327. assert.deepStrictEqual(decoded, msg);
  328. });
  329. test('roundtrip masked client JSON message', () => {
  330. const msg = { type: 'click', choice: 'a', text: 'Option A', timestamp: 1706000101 };
  331. const frame = makeClientFrame(0x01, JSON.stringify(msg));
  332. const result = ws.decodeFrame(frame);
  333. const decoded = JSON.parse(result.payload.toString());
  334. assert.deepStrictEqual(decoded, msg);
  335. });
  336. // ========== Summary ==========
  337. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  338. if (failed > 0) process.exit(1);
  339. }
  340. runTests();