@sideband/secure-relay 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,820 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ FRAME_HEADER_SIZE,
6
+ HANDSHAKE_ACCEPT_PAYLOAD_SIZE,
7
+ HANDSHAKE_INIT_PAYLOAD_SIZE,
8
+ MAX_PAYLOAD_SIZE,
9
+ MAX_PING_PAYLOAD_SIZE,
10
+ SIGNAL_PAYLOAD_SIZE,
11
+ } from "./constants.js";
12
+ import {
13
+ decodeControl,
14
+ decodeData,
15
+ decodeFrame,
16
+ decodeHandshakeAccept,
17
+ decodeHandshakeInit,
18
+ decodeSignal,
19
+ encodeControl,
20
+ encodeData,
21
+ encodeFrame,
22
+ encodeHandshakeAccept,
23
+ encodeHandshakeInit,
24
+ encodePing,
25
+ encodePong,
26
+ encodeSignal,
27
+ FrameDecoder,
28
+ FrameType,
29
+ fromWireControlCode,
30
+ isTerminalCode,
31
+ readFrameHeader,
32
+ toWireControlCode,
33
+ WireControlCode,
34
+ } from "./frame.js";
35
+ import type {
36
+ EncryptedMessage,
37
+ HandshakeAccept,
38
+ HandshakeInit,
39
+ } from "./types.js";
40
+ import { SbrpError, SbrpErrorCode, SignalCode, SignalReason } from "./types.js";
41
+
42
+ describe("frame codec", () => {
43
+ describe("encodeFrame / decodeFrame", () => {
44
+ it("roundtrips empty payload (connection-scoped)", () => {
45
+ const encoded = encodeFrame(FrameType.Ping, 0n, new Uint8Array(0));
46
+ expect(encoded.length).toBe(FRAME_HEADER_SIZE);
47
+
48
+ const decoded = decodeFrame(encoded);
49
+ expect(decoded.type).toBe(FrameType.Ping);
50
+ expect(decoded.sessionId).toBe(0n);
51
+ expect(decoded.length).toBe(0);
52
+ expect(decoded.payload.length).toBe(0);
53
+ });
54
+
55
+ it("roundtrips payload with data", () => {
56
+ const payload = new Uint8Array([1, 2, 3, 4, 5]);
57
+ const encoded = encodeFrame(FrameType.Data, 0xdeadbeefn, payload);
58
+ expect(encoded.length).toBe(FRAME_HEADER_SIZE + 5);
59
+
60
+ const decoded = decodeFrame(encoded);
61
+ expect(decoded.type).toBe(FrameType.Data);
62
+ expect(decoded.sessionId).toBe(0xdeadbeefn);
63
+ expect(decoded.payload).toEqual(payload);
64
+ });
65
+
66
+ it("handles max uint64 sessionId (session-bound)", () => {
67
+ const maxSessionId = 0xffff_ffff_ffff_ffffn;
68
+ const encoded = encodeFrame(
69
+ FrameType.Data,
70
+ maxSessionId,
71
+ new Uint8Array(28),
72
+ );
73
+ const decoded = decodeFrame(encoded);
74
+ expect(decoded.sessionId).toBe(maxSessionId);
75
+ });
76
+
77
+ it("rejects payload exceeding MAX_PAYLOAD_SIZE", () => {
78
+ const bigPayload = new Uint8Array(MAX_PAYLOAD_SIZE + 1);
79
+ expect(() => encodeFrame(FrameType.Data, 1n, bigPayload)).toThrow(
80
+ SbrpError,
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("readFrameHeader", () => {
86
+ it("reads header without full payload", () => {
87
+ const payload = new Uint8Array(100);
88
+ const encoded = encodeFrame(FrameType.Data, 42n, payload);
89
+ // Only pass header
90
+ const headerOnly = encoded.subarray(0, FRAME_HEADER_SIZE);
91
+
92
+ const header = readFrameHeader(headerOnly);
93
+ expect(header.type).toBe(FrameType.Data);
94
+ expect(header.sessionId).toBe(42n);
95
+ expect(header.length).toBe(100);
96
+ });
97
+
98
+ it("rejects buffer shorter than header", () => {
99
+ const short = new Uint8Array(FRAME_HEADER_SIZE - 1);
100
+ expect(() => readFrameHeader(short)).toThrow(SbrpError);
101
+ });
102
+
103
+ it("rejects invalid payload length in header", () => {
104
+ const frame = new Uint8Array(FRAME_HEADER_SIZE);
105
+ frame[0] = FrameType.Data;
106
+ // Set length to MAX_PAYLOAD_SIZE + 1
107
+ new DataView(frame.buffer).setUint32(1, MAX_PAYLOAD_SIZE + 1, false);
108
+ expect(() => readFrameHeader(frame)).toThrow(SbrpError);
109
+ });
110
+
111
+ it("rejects zero sessionId for session-bound frames", () => {
112
+ const frame = new Uint8Array(FRAME_HEADER_SIZE);
113
+ frame[0] = FrameType.HandshakeInit;
114
+ new DataView(frame.buffer).setUint32(1, 32, false);
115
+ // sessionId left as 0
116
+
117
+ expect(() => readFrameHeader(frame)).toThrow(SbrpError);
118
+ expect(() => readFrameHeader(frame)).toThrow(/non-zero sessionId/);
119
+ });
120
+
121
+ it("allows zero sessionId for connection-scoped frames", () => {
122
+ const frame = new Uint8Array(FRAME_HEADER_SIZE);
123
+ frame[0] = FrameType.Ping;
124
+ // sessionId = 0, length = 0
125
+
126
+ const header = readFrameHeader(frame);
127
+ expect(header.sessionId).toBe(0n);
128
+ expect(header.type).toBe(FrameType.Ping);
129
+ });
130
+
131
+ it("rejects non-zero sessionId for connection-scoped frames", () => {
132
+ const frame = new Uint8Array(FRAME_HEADER_SIZE);
133
+ frame[0] = FrameType.Ping;
134
+ new DataView(frame.buffer).setBigUint64(5, 123n, false);
135
+
136
+ expect(() => readFrameHeader(frame)).toThrow(SbrpError);
137
+ expect(() => readFrameHeader(frame)).toThrow(/sessionId = 0/);
138
+ });
139
+ });
140
+
141
+ describe("decodeFrame validation", () => {
142
+ it("rejects truncated frame", () => {
143
+ const payload = new Uint8Array(50);
144
+ const encoded = encodeFrame(FrameType.Data, 1n, payload);
145
+ // Truncate payload
146
+ const truncated = encoded.subarray(0, FRAME_HEADER_SIZE + 10);
147
+ expect(() => decodeFrame(truncated)).toThrow(SbrpError);
148
+ });
149
+
150
+ it("rejects trailing bytes", () => {
151
+ const frame1 = encodePing();
152
+ const frame2 = encodePong();
153
+ const combined = new Uint8Array(frame1.length + frame2.length);
154
+ combined.set(frame1, 0);
155
+ combined.set(frame2, frame1.length);
156
+
157
+ // decodeFrame should reject the combined buffer
158
+ expect(() => decodeFrame(combined)).toThrow(SbrpError);
159
+ expect(() => decodeFrame(combined)).toThrow(/trailing bytes/);
160
+ });
161
+ });
162
+
163
+ describe("sessionId validation", () => {
164
+ it("rejects zero sessionId for session-bound frames", () => {
165
+ const payload = new Uint8Array(32);
166
+ expect(() => encodeFrame(FrameType.HandshakeInit, 0n, payload)).toThrow(
167
+ SbrpError,
168
+ );
169
+ expect(() => encodeFrame(FrameType.HandshakeInit, 0n, payload)).toThrow(
170
+ /non-zero sessionId/,
171
+ );
172
+
173
+ expect(() =>
174
+ encodeFrame(FrameType.HandshakeAccept, 0n, new Uint8Array(96)),
175
+ ).toThrow(SbrpError);
176
+ expect(() => encodeFrame(FrameType.Data, 0n, new Uint8Array(28))).toThrow(
177
+ SbrpError,
178
+ );
179
+ expect(() =>
180
+ encodeFrame(FrameType.Signal, 0n, new Uint8Array(2)),
181
+ ).toThrow(SbrpError);
182
+ });
183
+
184
+ it("requires zero sessionId for connection-scoped frames", () => {
185
+ expect(() => encodePing()).not.toThrow();
186
+ expect(() => encodePong()).not.toThrow();
187
+ // Control frame can have any sessionId (0 for errors, non-zero for session events)
188
+ expect(() =>
189
+ encodeControl(0n, WireControlCode.RateLimited),
190
+ ).not.toThrow();
191
+ expect(() =>
192
+ encodeControl(1n, WireControlCode.SessionPaused),
193
+ ).not.toThrow();
194
+ });
195
+
196
+ it("rejects negative sessionId", () => {
197
+ expect(() => encodeFrame(FrameType.Ping, -1n, new Uint8Array(0))).toThrow(
198
+ SbrpError,
199
+ );
200
+ expect(() => encodeFrame(FrameType.Ping, -1n, new Uint8Array(0))).toThrow(
201
+ /out of uint64 range/,
202
+ );
203
+ });
204
+
205
+ it("rejects sessionId exceeding uint64", () => {
206
+ const tooBig = 0x1_0000_0000_0000_0000n; // 2^64
207
+ expect(() =>
208
+ encodeFrame(FrameType.Ping, tooBig, new Uint8Array(0)),
209
+ ).toThrow(SbrpError);
210
+ });
211
+ });
212
+
213
+ describe("payload size validation", () => {
214
+ it("rejects wrong initPublicKey size", () => {
215
+ const wrongSize: HandshakeInit = {
216
+ type: "handshake.init",
217
+ initPublicKey: new Uint8Array(16), // should be 32
218
+ };
219
+ expect(() => encodeHandshakeInit(1n, wrongSize)).toThrow(SbrpError);
220
+ expect(() => encodeHandshakeInit(1n, wrongSize)).toThrow(
221
+ /must be 32 bytes/,
222
+ );
223
+ });
224
+
225
+ it("rejects wrong acceptPublicKey size", () => {
226
+ const wrongSize: HandshakeAccept = {
227
+ type: "handshake.accept",
228
+ acceptPublicKey: new Uint8Array(16), // should be 32
229
+ signature: new Uint8Array(64),
230
+ };
231
+ expect(() => encodeHandshakeAccept(1n, wrongSize)).toThrow(SbrpError);
232
+ });
233
+
234
+ it("rejects wrong signature size", () => {
235
+ const wrongSize: HandshakeAccept = {
236
+ type: "handshake.accept",
237
+ acceptPublicKey: new Uint8Array(32),
238
+ signature: new Uint8Array(32), // should be 64
239
+ };
240
+ expect(() => encodeHandshakeAccept(1n, wrongSize)).toThrow(SbrpError);
241
+ expect(() => encodeHandshakeAccept(1n, wrongSize)).toThrow(
242
+ /signature must be 64 bytes/,
243
+ );
244
+ });
245
+
246
+ it("rejects Data payload too short", () => {
247
+ const tooShort: EncryptedMessage = {
248
+ type: "encrypted",
249
+ seq: 0n,
250
+ data: new Uint8Array(20), // must be >= 28
251
+ };
252
+ expect(() => encodeData(1n, tooShort)).toThrow(SbrpError);
253
+ expect(() => encodeData(1n, tooShort)).toThrow(/at least 28 bytes/);
254
+ });
255
+
256
+ it("rejects Ping payload too large", () => {
257
+ const tooLarge = new Uint8Array(MAX_PING_PAYLOAD_SIZE + 1);
258
+ expect(() => encodePing(tooLarge)).toThrow(SbrpError);
259
+ expect(() => encodePing(tooLarge)).toThrow(/0-8 bytes/);
260
+ });
261
+ });
262
+
263
+ describe("HandshakeInit", () => {
264
+ it("encodes and decodes correctly", () => {
265
+ const initPublicKey = new Uint8Array(32).fill(0xab);
266
+ const init: HandshakeInit = {
267
+ type: "handshake.init",
268
+ initPublicKey,
269
+ };
270
+
271
+ const encoded = encodeHandshakeInit(1n, init);
272
+ expect(encoded.length).toBe(
273
+ FRAME_HEADER_SIZE + HANDSHAKE_INIT_PAYLOAD_SIZE,
274
+ );
275
+
276
+ const frame = decodeFrame(encoded);
277
+ expect(frame.type).toBe(FrameType.HandshakeInit);
278
+
279
+ const decoded = decodeHandshakeInit(frame);
280
+ expect(decoded.type).toBe("handshake.init");
281
+ expect(decoded.initPublicKey).toEqual(initPublicKey);
282
+ });
283
+
284
+ it("rejects wrong payload size", () => {
285
+ // Create frame with wrong payload size
286
+ const wrongPayload = new Uint8Array(16);
287
+ const encoded = encodeFrame(FrameType.HandshakeInit, 1n, wrongPayload);
288
+ const frame = decodeFrame(encoded);
289
+ expect(() => decodeHandshakeInit(frame)).toThrow(SbrpError);
290
+ });
291
+
292
+ it("rejects wrong frame type", () => {
293
+ const payload = new Uint8Array(32);
294
+ const encoded = encodeFrame(FrameType.Data, 1n, payload);
295
+ const frame = decodeFrame(encoded);
296
+ expect(() => decodeHandshakeInit(frame)).toThrow(SbrpError);
297
+ });
298
+
299
+ it("rejects zero sessionId on decode", () => {
300
+ // Manually construct frame with sessionId=0 (bypassing encode validation)
301
+ const payload = new Uint8Array(32);
302
+ const frame = new Uint8Array(FRAME_HEADER_SIZE + 32);
303
+ frame[0] = FrameType.HandshakeInit;
304
+ new DataView(frame.buffer).setUint32(1, 32, false);
305
+ // sessionId left as 0
306
+ frame.set(payload, FRAME_HEADER_SIZE);
307
+
308
+ // Validation now happens at decodeFrame level (via readFrameHeader)
309
+ expect(() => decodeFrame(frame)).toThrow(SbrpError);
310
+ expect(() => decodeFrame(frame)).toThrow(/non-zero sessionId/);
311
+ });
312
+ });
313
+
314
+ describe("HandshakeAccept", () => {
315
+ it("encodes and decodes correctly", () => {
316
+ const acceptPublicKey = new Uint8Array(32).fill(0xcd);
317
+ const signature = new Uint8Array(64).fill(0xef);
318
+ const accept: HandshakeAccept = {
319
+ type: "handshake.accept",
320
+ acceptPublicKey,
321
+ signature,
322
+ };
323
+
324
+ const encoded = encodeHandshakeAccept(2n, accept);
325
+ expect(encoded.length).toBe(
326
+ FRAME_HEADER_SIZE + HANDSHAKE_ACCEPT_PAYLOAD_SIZE,
327
+ );
328
+
329
+ const frame = decodeFrame(encoded);
330
+ expect(frame.type).toBe(FrameType.HandshakeAccept);
331
+
332
+ const decoded = decodeHandshakeAccept(frame);
333
+ expect(decoded.type).toBe("handshake.accept");
334
+ expect(decoded.acceptPublicKey).toEqual(acceptPublicKey);
335
+ expect(decoded.signature).toEqual(signature);
336
+ });
337
+
338
+ it("rejects wrong payload size", () => {
339
+ const wrongPayload = new Uint8Array(50);
340
+ const encoded = encodeFrame(FrameType.HandshakeAccept, 1n, wrongPayload);
341
+ const frame = decodeFrame(encoded);
342
+ expect(() => decodeHandshakeAccept(frame)).toThrow(SbrpError);
343
+ });
344
+
345
+ it("rejects zero sessionId on decode", () => {
346
+ const payload = new Uint8Array(96);
347
+ const frame = new Uint8Array(FRAME_HEADER_SIZE + 96);
348
+ frame[0] = FrameType.HandshakeAccept;
349
+ new DataView(frame.buffer).setUint32(1, 96, false);
350
+ frame.set(payload, FRAME_HEADER_SIZE);
351
+
352
+ // Validation happens at decodeFrame level (via readFrameHeader)
353
+ expect(() => decodeFrame(frame)).toThrow(/non-zero sessionId/);
354
+ });
355
+ });
356
+
357
+ describe("Data (encrypted)", () => {
358
+ it("encodes and decodes correctly", () => {
359
+ // Minimum: nonce (12) + authTag (16) = 28 bytes
360
+ const data = new Uint8Array(28 + 10); // 10 bytes ciphertext
361
+ // Set nonce with sequence number 42 (bytes 4-11)
362
+ new DataView(data.buffer).setBigUint64(4, 42n, false);
363
+
364
+ const message: EncryptedMessage = {
365
+ type: "encrypted",
366
+ seq: 42n,
367
+ data,
368
+ };
369
+
370
+ const encoded = encodeData(3n, message);
371
+ const frame = decodeFrame(encoded);
372
+ expect(frame.type).toBe(FrameType.Data);
373
+
374
+ const decoded = decodeData(frame);
375
+ expect(decoded.type).toBe("encrypted");
376
+ expect(decoded.seq).toBe(42n);
377
+ expect(decoded.data).toEqual(data);
378
+ });
379
+
380
+ it("rejects payload too short for nonce+tag", () => {
381
+ const shortPayload = new Uint8Array(20);
382
+ const encoded = encodeFrame(FrameType.Data, 1n, shortPayload);
383
+ const frame = decodeFrame(encoded);
384
+ expect(() => decodeData(frame)).toThrow(SbrpError);
385
+ });
386
+
387
+ it("rejects zero sessionId on decode", () => {
388
+ const payload = new Uint8Array(28);
389
+ const frame = new Uint8Array(FRAME_HEADER_SIZE + 28);
390
+ frame[0] = FrameType.Data;
391
+ new DataView(frame.buffer).setUint32(1, 28, false);
392
+ frame.set(payload, FRAME_HEADER_SIZE);
393
+
394
+ // Validation happens at decodeFrame level (via readFrameHeader)
395
+ expect(() => decodeFrame(frame)).toThrow(/non-zero sessionId/);
396
+ });
397
+ });
398
+
399
+ describe("Ping / Pong", () => {
400
+ it("encodes Ping with zero sessionId (connection-scoped)", () => {
401
+ const ping = encodePing();
402
+ const frame = decodeFrame(ping);
403
+ expect(frame.type).toBe(FrameType.Ping);
404
+ expect(frame.sessionId).toBe(0n);
405
+ expect(frame.payload.length).toBe(0);
406
+ });
407
+
408
+ it("encodes Pong with zero sessionId", () => {
409
+ const pong = encodePong();
410
+ const frame = decodeFrame(pong);
411
+ expect(frame.type).toBe(FrameType.Pong);
412
+ expect(frame.sessionId).toBe(0n);
413
+ });
414
+
415
+ it("roundtrips payload for RTT measurement", () => {
416
+ const rttNonce = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
417
+ const ping = encodePing(rttNonce);
418
+ const frame = decodeFrame(ping);
419
+ expect(frame.payload).toEqual(rttNonce);
420
+
421
+ // Pong should echo the same payload
422
+ const pong = encodePong(rttNonce);
423
+ const pongFrame = decodeFrame(pong);
424
+ expect(pongFrame.payload).toEqual(rttNonce);
425
+ });
426
+ });
427
+
428
+ describe("Signal", () => {
429
+ it("encodes and decodes ready signal", () => {
430
+ const encoded = encodeSignal(5n, SignalCode.Ready);
431
+ const frame = decodeFrame(encoded);
432
+ expect(frame.type).toBe(FrameType.Signal);
433
+ expect(frame.sessionId).toBe(5n);
434
+
435
+ const signal = decodeSignal(frame);
436
+ expect(signal.signal).toBe(SignalCode.Ready);
437
+ expect(signal.reason).toBe(0);
438
+ });
439
+
440
+ it("encodes and decodes close signal with reason", () => {
441
+ const encoded = encodeSignal(
442
+ 5n,
443
+ SignalCode.Close,
444
+ SignalReason.StateLost,
445
+ );
446
+ const frame = decodeFrame(encoded);
447
+
448
+ const signal = decodeSignal(frame);
449
+ expect(signal.signal).toBe(SignalCode.Close);
450
+ expect(signal.reason).toBe(SignalReason.StateLost);
451
+ });
452
+
453
+ it("supports all reason codes", () => {
454
+ for (const reason of [
455
+ SignalReason.None,
456
+ SignalReason.StateLost,
457
+ SignalReason.Shutdown,
458
+ SignalReason.Policy,
459
+ SignalReason.Error,
460
+ ]) {
461
+ const encoded = encodeSignal(1n, SignalCode.Close, reason);
462
+ const frame = decodeFrame(encoded);
463
+ const signal = decodeSignal(frame);
464
+ expect(signal.reason).toBe(reason);
465
+ }
466
+ });
467
+
468
+ it("uses None as default reason for ready signal", () => {
469
+ const encoded = encodeSignal(5n, SignalCode.Ready);
470
+ const frame = decodeFrame(encoded);
471
+ const signal = decodeSignal(frame);
472
+ expect(signal.reason).toBe(SignalReason.None);
473
+ });
474
+
475
+ it("rejects wrong payload size", () => {
476
+ const wrongPayload = new Uint8Array(3);
477
+ const encoded = encodeFrame(FrameType.Signal, 1n, wrongPayload);
478
+ const frame = decodeFrame(encoded);
479
+ expect(() => decodeSignal(frame)).toThrow(SbrpError);
480
+ expect(() => decodeSignal(frame)).toThrow(
481
+ new RegExp(`${SIGNAL_PAYLOAD_SIZE} bytes`),
482
+ );
483
+ });
484
+ });
485
+
486
+ describe("Control", () => {
487
+ it("encodes and decodes with message", () => {
488
+ const encoded = encodeControl(
489
+ 0n,
490
+ WireControlCode.Unauthorized,
491
+ "Access denied",
492
+ );
493
+ const frame = decodeFrame(encoded);
494
+ expect(frame.type).toBe(FrameType.Control);
495
+
496
+ const control = decodeControl(frame);
497
+ expect(control.code).toBe(WireControlCode.Unauthorized);
498
+ expect(control.message).toBe("Access denied");
499
+ });
500
+
501
+ it("encodes and decodes without message", () => {
502
+ const encoded = encodeControl(0n, WireControlCode.RateLimited);
503
+ const frame = decodeFrame(encoded);
504
+ const control = decodeControl(frame);
505
+ expect(control.code).toBe(WireControlCode.RateLimited);
506
+ expect(control.message).toBe("");
507
+ });
508
+
509
+ it("encodes session state notifications", () => {
510
+ const encoded = encodeControl(5n, WireControlCode.SessionPaused);
511
+ const frame = decodeFrame(encoded);
512
+ expect(frame.sessionId).toBe(5n);
513
+
514
+ const control = decodeControl(frame);
515
+ expect(control.code).toBe(WireControlCode.SessionPaused);
516
+ });
517
+
518
+ it("handles invalid UTF-8 by replacing", () => {
519
+ // Create control frame with invalid UTF-8 in message
520
+ const payload = new Uint8Array([0x01, 0x01, 0xff, 0xfe]); // code 0x0101 + invalid UTF-8
521
+ const encoded = encodeFrame(FrameType.Control, 0n, payload);
522
+ const frame = decodeFrame(encoded);
523
+ const control = decodeControl(frame);
524
+ // Invalid bytes should be replaced with U+FFFD
525
+ expect(control.message).toContain("\ufffd");
526
+ });
527
+
528
+ it("rejects payload too short", () => {
529
+ const shortPayload = new Uint8Array(1);
530
+ const encoded = encodeFrame(FrameType.Control, 0n, shortPayload);
531
+ const frame = decodeFrame(encoded);
532
+ expect(() => decodeControl(frame)).toThrow(SbrpError);
533
+ });
534
+ });
535
+
536
+ describe("isTerminalCode", () => {
537
+ it("returns true for terminal codes", () => {
538
+ expect(isTerminalCode(WireControlCode.Unauthorized)).toBe(true);
539
+ expect(isTerminalCode(WireControlCode.Forbidden)).toBe(true);
540
+ expect(isTerminalCode(WireControlCode.DaemonNotFound)).toBe(true);
541
+ expect(isTerminalCode(WireControlCode.SessionNotFound)).toBe(true);
542
+ expect(isTerminalCode(WireControlCode.SessionExpired)).toBe(true);
543
+ expect(isTerminalCode(WireControlCode.MalformedFrame)).toBe(true);
544
+ expect(isTerminalCode(WireControlCode.PayloadTooLarge)).toBe(true);
545
+ expect(isTerminalCode(WireControlCode.InvalidFrameType)).toBe(true);
546
+ expect(isTerminalCode(WireControlCode.InvalidSessionId)).toBe(true);
547
+ expect(isTerminalCode(WireControlCode.DisallowedSender)).toBe(true);
548
+ expect(isTerminalCode(WireControlCode.InternalError)).toBe(true);
549
+ });
550
+
551
+ it("returns false for non-terminal codes", () => {
552
+ expect(isTerminalCode(WireControlCode.DaemonOffline)).toBe(false);
553
+ expect(isTerminalCode(WireControlCode.RateLimited)).toBe(false);
554
+ expect(isTerminalCode(WireControlCode.SessionPaused)).toBe(false);
555
+ expect(isTerminalCode(WireControlCode.SessionResumed)).toBe(false);
556
+ expect(isTerminalCode(WireControlCode.SessionEnded)).toBe(false);
557
+ expect(isTerminalCode(WireControlCode.SessionPending)).toBe(false);
558
+ });
559
+ });
560
+
561
+ describe("wire code values (§14.1 compliance)", () => {
562
+ it("uses correct hex values for wire codes", () => {
563
+ // Authentication (0x01xx)
564
+ expect(WireControlCode.Unauthorized).toBe(0x0101);
565
+ expect(WireControlCode.Forbidden).toBe(0x0102);
566
+
567
+ // Routing (0x02xx)
568
+ expect(WireControlCode.DaemonNotFound).toBe(0x0201);
569
+ expect(WireControlCode.DaemonOffline).toBe(0x0202);
570
+
571
+ // Session (0x03xx)
572
+ expect(WireControlCode.SessionNotFound).toBe(0x0301);
573
+ expect(WireControlCode.SessionExpired).toBe(0x0302);
574
+
575
+ // Wire Format (0x04xx)
576
+ expect(WireControlCode.MalformedFrame).toBe(0x0401);
577
+ expect(WireControlCode.PayloadTooLarge).toBe(0x0402);
578
+ expect(WireControlCode.InvalidFrameType).toBe(0x0403);
579
+ expect(WireControlCode.InvalidSessionId).toBe(0x0404);
580
+ expect(WireControlCode.DisallowedSender).toBe(0x0405);
581
+
582
+ // Internal (0x06xx)
583
+ expect(WireControlCode.InternalError).toBe(0x0601);
584
+
585
+ // Rate Limiting (0x09xx)
586
+ expect(WireControlCode.RateLimited).toBe(0x0901);
587
+
588
+ // Session State (0x10xx)
589
+ expect(WireControlCode.SessionPaused).toBe(0x1001);
590
+ expect(WireControlCode.SessionResumed).toBe(0x1002);
591
+ expect(WireControlCode.SessionEnded).toBe(0x1003);
592
+ expect(WireControlCode.SessionPending).toBe(0x1004);
593
+ });
594
+
595
+ it("encodes InvalidSessionId control frame correctly", () => {
596
+ const encoded = encodeControl(
597
+ 0n,
598
+ WireControlCode.InvalidSessionId,
599
+ "test",
600
+ );
601
+ const frame = decodeFrame(encoded);
602
+ const control = decodeControl(frame);
603
+ expect(control.code).toBe(0x0404);
604
+ });
605
+
606
+ it("encodes InternalError control frame correctly", () => {
607
+ const encoded = encodeControl(0n, WireControlCode.InternalError, "test");
608
+ const frame = decodeFrame(encoded);
609
+ const control = decodeControl(frame);
610
+ expect(control.code).toBe(0x0601);
611
+ });
612
+ });
613
+
614
+ describe("signal reason values (§13.4 compliance)", () => {
615
+ it("uses correct hex values for signal reasons", () => {
616
+ expect(SignalReason.None).toBe(0x00);
617
+ expect(SignalReason.StateLost).toBe(0x01);
618
+ expect(SignalReason.Shutdown).toBe(0x02);
619
+ expect(SignalReason.Policy).toBe(0x03);
620
+ expect(SignalReason.Error).toBe(0x04);
621
+ });
622
+
623
+ it("encodes signal with StateLost reason at 0x01", () => {
624
+ const encoded = encodeSignal(
625
+ 1n,
626
+ SignalCode.Close,
627
+ SignalReason.StateLost,
628
+ );
629
+ const frame = decodeFrame(encoded);
630
+ expect(frame.payload[1]).toBe(0x01);
631
+ });
632
+
633
+ it("encodes signal with Error reason at 0x04", () => {
634
+ const encoded = encodeSignal(1n, SignalCode.Close, SignalReason.Error);
635
+ const frame = decodeFrame(encoded);
636
+ expect(frame.payload[1]).toBe(0x04);
637
+ });
638
+ });
639
+
640
+ describe("control code conversion", () => {
641
+ it("converts wire-transmittable SbrpErrorCodes to WireControlCode", () => {
642
+ // Authentication
643
+ expect(toWireControlCode(SbrpErrorCode.Unauthorized)).toBe(
644
+ WireControlCode.Unauthorized,
645
+ );
646
+ expect(toWireControlCode(SbrpErrorCode.Forbidden)).toBe(
647
+ WireControlCode.Forbidden,
648
+ );
649
+
650
+ // Routing
651
+ expect(toWireControlCode(SbrpErrorCode.DaemonNotFound)).toBe(
652
+ WireControlCode.DaemonNotFound,
653
+ );
654
+ expect(toWireControlCode(SbrpErrorCode.DaemonOffline)).toBe(
655
+ WireControlCode.DaemonOffline,
656
+ );
657
+
658
+ // Session
659
+ expect(toWireControlCode(SbrpErrorCode.SessionNotFound)).toBe(
660
+ WireControlCode.SessionNotFound,
661
+ );
662
+ expect(toWireControlCode(SbrpErrorCode.SessionExpired)).toBe(
663
+ WireControlCode.SessionExpired,
664
+ );
665
+
666
+ // Wire format
667
+ expect(toWireControlCode(SbrpErrorCode.MalformedFrame)).toBe(
668
+ WireControlCode.MalformedFrame,
669
+ );
670
+ expect(toWireControlCode(SbrpErrorCode.PayloadTooLarge)).toBe(
671
+ WireControlCode.PayloadTooLarge,
672
+ );
673
+ expect(toWireControlCode(SbrpErrorCode.InvalidFrameType)).toBe(
674
+ WireControlCode.InvalidFrameType,
675
+ );
676
+ expect(toWireControlCode(SbrpErrorCode.InvalidSessionId)).toBe(
677
+ WireControlCode.InvalidSessionId,
678
+ );
679
+ expect(toWireControlCode(SbrpErrorCode.DisallowedSender)).toBe(
680
+ WireControlCode.DisallowedSender,
681
+ );
682
+
683
+ // Internal
684
+ expect(toWireControlCode(SbrpErrorCode.InternalError)).toBe(
685
+ WireControlCode.InternalError,
686
+ );
687
+
688
+ // Rate limiting
689
+ expect(toWireControlCode(SbrpErrorCode.RateLimited)).toBe(
690
+ WireControlCode.RateLimited,
691
+ );
692
+
693
+ // Session state
694
+ expect(toWireControlCode(SbrpErrorCode.SessionPaused)).toBe(
695
+ WireControlCode.SessionPaused,
696
+ );
697
+ expect(toWireControlCode(SbrpErrorCode.SessionResumed)).toBe(
698
+ WireControlCode.SessionResumed,
699
+ );
700
+ expect(toWireControlCode(SbrpErrorCode.SessionEnded)).toBe(
701
+ WireControlCode.SessionEnded,
702
+ );
703
+ expect(toWireControlCode(SbrpErrorCode.SessionPending)).toBe(
704
+ WireControlCode.SessionPending,
705
+ );
706
+ });
707
+
708
+ it("converts all WireControlCode to SbrpErrorCode", () => {
709
+ expect(fromWireControlCode(WireControlCode.Unauthorized)).toBe(
710
+ SbrpErrorCode.Unauthorized,
711
+ );
712
+ expect(fromWireControlCode(WireControlCode.MalformedFrame)).toBe(
713
+ SbrpErrorCode.MalformedFrame,
714
+ );
715
+ expect(fromWireControlCode(WireControlCode.InvalidSessionId)).toBe(
716
+ SbrpErrorCode.InvalidSessionId,
717
+ );
718
+ expect(fromWireControlCode(WireControlCode.InternalError)).toBe(
719
+ SbrpErrorCode.InternalError,
720
+ );
721
+ expect(fromWireControlCode(WireControlCode.SessionPaused)).toBe(
722
+ SbrpErrorCode.SessionPaused,
723
+ );
724
+ });
725
+
726
+ it("throws on endpoint-only codes (never transmitted on wire)", () => {
727
+ expect(() =>
728
+ toWireControlCode(SbrpErrorCode.IdentityKeyChanged),
729
+ ).toThrow();
730
+ expect(() => toWireControlCode(SbrpErrorCode.HandshakeFailed)).toThrow();
731
+ expect(() => toWireControlCode(SbrpErrorCode.HandshakeTimeout)).toThrow();
732
+ expect(() => toWireControlCode(SbrpErrorCode.DecryptFailed)).toThrow();
733
+ expect(() => toWireControlCode(SbrpErrorCode.SequenceError)).toThrow();
734
+ });
735
+
736
+ it("throws on unknown wire codes", () => {
737
+ expect(() => fromWireControlCode(0x9999 as WireControlCode)).toThrow();
738
+ });
739
+ });
740
+
741
+ describe("FrameDecoder", () => {
742
+ it("decodes single complete frame", () => {
743
+ const decoder = new FrameDecoder();
744
+ const frame = encodePing();
745
+ const frames = [...decoder.push(frame)];
746
+ expect(frames.length).toBe(1);
747
+ expect(frames[0].type).toBe(FrameType.Ping);
748
+ expect(frames[0].sessionId).toBe(0n);
749
+ });
750
+
751
+ it("decodes multiple frames in one push", () => {
752
+ const decoder = new FrameDecoder();
753
+ const frame1 = encodePing();
754
+ const frame2 = encodePong();
755
+ const combined = new Uint8Array(frame1.length + frame2.length);
756
+ combined.set(frame1, 0);
757
+ combined.set(frame2, frame1.length);
758
+
759
+ const frames = [...decoder.push(combined)];
760
+ expect(frames.length).toBe(2);
761
+ expect(frames[0].type).toBe(FrameType.Ping);
762
+ expect(frames[1].type).toBe(FrameType.Pong);
763
+ });
764
+
765
+ it("buffers incomplete frames", () => {
766
+ const decoder = new FrameDecoder();
767
+ const frame = encodeControl(0n, WireControlCode.RateLimited, "slow down");
768
+
769
+ // Push header only
770
+ let frames = [...decoder.push(frame.subarray(0, FRAME_HEADER_SIZE))];
771
+ expect(frames.length).toBe(0);
772
+ expect(decoder.bufferedBytes).toBe(FRAME_HEADER_SIZE);
773
+
774
+ // Push rest
775
+ frames = [...decoder.push(frame.subarray(FRAME_HEADER_SIZE))];
776
+ expect(frames.length).toBe(1);
777
+ expect(frames[0].type).toBe(FrameType.Control);
778
+ expect(decoder.bufferedBytes).toBe(0);
779
+ });
780
+
781
+ it("handles byte-by-byte streaming", () => {
782
+ const decoder = new FrameDecoder();
783
+ const frame = encodePing();
784
+ const allFrames: typeof frame extends Uint8Array
785
+ ? ReturnType<typeof decodeFrame>[]
786
+ : never = [];
787
+
788
+ for (let i = 0; i < frame.length; i++) {
789
+ const frames = [...decoder.push(frame.subarray(i, i + 1))];
790
+ allFrames.push(...frames);
791
+ }
792
+
793
+ expect(allFrames.length).toBe(1);
794
+ expect(allFrames[0].type).toBe(FrameType.Ping);
795
+ });
796
+
797
+ it("resets state correctly", () => {
798
+ const decoder = new FrameDecoder();
799
+ const frame = encodePing();
800
+ // Must consume the generator to trigger buffering
801
+ [...decoder.push(frame.subarray(0, 5))];
802
+ expect(decoder.bufferedBytes).toBe(5);
803
+
804
+ decoder.reset();
805
+ expect(decoder.bufferedBytes).toBe(0);
806
+ });
807
+
808
+ it("rejects invalid frames with zero sessionId", () => {
809
+ const decoder = new FrameDecoder();
810
+ // Manually construct invalid HandshakeInit with sessionId=0
811
+ const frame = new Uint8Array(FRAME_HEADER_SIZE + 32);
812
+ frame[0] = FrameType.HandshakeInit;
813
+ new DataView(frame.buffer).setUint32(1, 32, false);
814
+ // sessionId left as 0
815
+ frame.set(new Uint8Array(32), FRAME_HEADER_SIZE);
816
+
817
+ expect(() => [...decoder.push(frame)]).toThrow(/non-zero sessionId/);
818
+ });
819
+ });
820
+ });