Move tests to their own folder

This commit is contained in:
2026-03-10 18:12:42 +01:00
parent 7eca26a2b2
commit df09c952de
4 changed files with 10 additions and 10 deletions

202
test/crypto.test.ts Normal file
View File

@@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest';
import { PublicKey, PrivateKey, SharedSecret, StaticSecret } from '../src/crypto';
import { bytesToHex, hexToBytes } from '../src/parser';
const randomBytes = (len: number) => Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256));
describe('PublicKey', () => {
const keyBytes = randomBytes(32);
const keyHex = bytesToHex(keyBytes);
it('constructs from Uint8Array', () => {
const pk = new PublicKey(keyBytes);
expect(pk.toBytes()).toEqual(keyBytes);
});
it('constructs from string', () => {
const pk = new PublicKey(keyHex);
expect(pk.toBytes()).toEqual(keyBytes);
});
it('throws on invalid constructor input', () => {
// @ts-expect-error
expect(() => new PublicKey(123)).toThrow();
});
it('toHash returns a NodeHash', () => {
const pk = new PublicKey(keyBytes);
expect(typeof pk.toHash()).toBe('number');
});
it('toString returns hex', () => {
const pk = new PublicKey(keyBytes);
expect(pk.toString()).toBe(keyHex);
});
it('equals works for PublicKey, Uint8Array, and string', () => {
const pk = new PublicKey(keyBytes);
expect(pk.equals(pk)).toBe(true);
expect(pk.equals(keyBytes)).toBe(true);
expect(pk.equals(keyHex)).toBe(true);
expect(pk.equals(randomBytes(32))).toBe(false);
});
it('throws on equals with invalid type', () => {
const pk = new PublicKey(keyBytes);
// @ts-expect-error
expect(() => pk.equals(123)).toThrow();
});
it('verify returns false for invalid signature', () => {
const pk = new PublicKey(keyBytes);
expect(pk.verify(new Uint8Array([1, 2, 3]), randomBytes(64))).toBe(false);
});
it('throws on verify with wrong signature length', () => {
const pk = new PublicKey(keyBytes);
expect(() => pk.verify(new Uint8Array([1, 2, 3]), randomBytes(10))).toThrow();
});
});
describe('PrivateKey', () => {
const seed = randomBytes(32);
it('constructs from Uint8Array', () => {
const sk = new PrivateKey(seed);
expect(sk.toPublicKey()).toBeInstanceOf(PublicKey);
});
it('constructs from string', () => {
const sk = new PrivateKey(bytesToHex(seed));
expect(sk.toPublicKey()).toBeInstanceOf(PublicKey);
});
it('throws on invalid seed length', () => {
expect(() => new PrivateKey(randomBytes(10))).toThrow();
});
it('sign and verify', () => {
const sk = new PrivateKey(seed);
const pk = sk.toPublicKey();
const msg = new Uint8Array([1, 2, 3]);
const sig = sk.sign(msg);
expect(pk.verify(msg, sig)).toBe(true);
});
it('calculateSharedSecret returns Uint8Array', () => {
const sk1 = new PrivateKey(seed);
const sk2 = PrivateKey.generate();
const pk2 = sk2.toPublicKey();
const secret = sk1.calculateSharedSecret(pk2);
expect(secret).toBeInstanceOf(Uint8Array);
expect(secret.length).toBeGreaterThan(0);
});
it('calculateSharedSecret accepts string and Uint8Array', () => {
const sk1 = new PrivateKey(seed);
const sk2 = PrivateKey.generate();
const pk2 = sk2.toPublicKey();
expect(sk1.calculateSharedSecret(pk2.toBytes())).toBeInstanceOf(Uint8Array);
expect(sk1.calculateSharedSecret(pk2.toString())).toBeInstanceOf(Uint8Array);
});
it('throws on calculateSharedSecret with invalid type', () => {
const sk = new PrivateKey(seed);
// @ts-expect-error
expect(() => sk.calculateSharedSecret(123)).toThrow();
});
it('generate returns PrivateKey', () => {
expect(PrivateKey.generate()).toBeInstanceOf(PrivateKey);
});
});
describe('SharedSecret', () => {
const secret = randomBytes(32);
it('constructs from 32 bytes', () => {
const ss = new SharedSecret(secret);
expect(ss.toBytes()).toEqual(secret);
});
it('pads if 16 bytes', () => {
const short = randomBytes(16);
const ss = new SharedSecret(short);
expect(ss.toBytes().length).toBe(32);
expect(Array.from(ss.toBytes()).slice(16)).toEqual(Array.from(short));
});
it('throws on invalid length', () => {
expect(() => new SharedSecret(randomBytes(10))).toThrow();
});
it('toHash returns number', () => {
const ss = new SharedSecret(secret);
expect(typeof ss.toHash()).toBe('number');
});
it('toString returns hex', () => {
const ss = new SharedSecret(secret);
expect(ss.toString()).toBe(bytesToHex(secret));
});
it('encrypt and decrypt roundtrip', () => {
const ss = new SharedSecret(secret);
const data = new Uint8Array([1, 2, 3, 4, 5]);
const { hmac, ciphertext } = ss.encrypt(data);
const decrypted = ss.decrypt(hmac, ciphertext);
expect(Array.from(decrypted.slice(0, data.length))).toEqual(Array.from(data));
});
it('throws on decrypt with wrong hmac', () => {
const ss = new SharedSecret(secret);
const data = new Uint8Array([1, 2, 3]);
const { ciphertext } = ss.encrypt(data);
expect(() => ss.decrypt(new Uint8Array([0, 0]), ciphertext)).toThrow();
});
it('throws on decrypt with wrong hmac length', () => {
const ss = new SharedSecret(secret);
expect(() => ss.decrypt(new Uint8Array([1]), new Uint8Array([1, 2, 3]))).toThrow();
});
it('fromName "Public"', () => {
const ss = SharedSecret.fromName("Public");
expect(ss).toBeInstanceOf(SharedSecret);
});
it('fromName with #group', () => {
const ss = SharedSecret.fromName("#group");
expect(ss).toBeInstanceOf(SharedSecret);
});
it('fromName throws on invalid name', () => {
expect(() => SharedSecret.fromName("foo")).toThrow();
});
});
describe('StaticSecret', () => {
const secret = randomBytes(32);
it('constructs from Uint8Array', () => {
const ss = new StaticSecret(secret);
expect(ss.publicKey()).toBeInstanceOf(PublicKey);
});
it('constructs from string', () => {
const ss = new StaticSecret(bytesToHex(secret));
expect(ss.publicKey()).toBeInstanceOf(PublicKey);
});
it('throws on invalid length', () => {
expect(() => new StaticSecret(randomBytes(10))).toThrow();
});
it('diffieHellman returns SharedSecret', () => {
const ss1 = new StaticSecret(secret);
const ss2 = new StaticSecret(randomBytes(32));
const pk2 = ss2.publicKey();
const shared = ss1.diffieHellman(pk2);
expect(shared).toBeInstanceOf(SharedSecret);
});
});

478
test/identity.test.ts Normal file
View File

@@ -0,0 +1,478 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Identity, LocalIdentity, Contact, Group, Contacts, parseNodeHash } from '../src/identity';
import { PrivateKey, PublicKey, SharedSecret } from '../src/crypto';
import { DecryptedGroupText, DecryptedGroupData } from '../src/packet.types';
import { bytesToHex } from '../src/parser';
function randomBytes(len: number) {
return Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256));
}
describe('parseNodeHash', () => {
it('parses Uint8Array', () => {
expect(parseNodeHash(Uint8Array.of(0x42))).toBe(0x42);
});
it('parses number', () => {
expect(parseNodeHash(0x42)).toBe(0x42);
expect(() => parseNodeHash(-1)).toThrow();
expect(() => parseNodeHash(256)).toThrow();
});
it('parses string', () => {
expect(parseNodeHash('2a')).toBe(0x2a);
expect(() => parseNodeHash('2a2a')).toThrow();
});
it('throws on invalid type', () => {
// @ts-expect-error
expect(() => parseNodeHash({})).toThrow();
});
});
describe('Identity', () => {
let pub: Uint8Array;
let identity: Identity;
beforeEach(() => {
pub = randomBytes(32);
identity = new Identity(pub);
});
it('constructs from Uint8Array', () => {
expect(identity.publicKey.toBytes()).toEqual(pub);
});
it('constructs from string', () => {
const hex = bytesToHex(pub);
const id = new Identity(hex);
expect(id.publicKey.toBytes()).toEqual(pub);
});
it('hash returns NodeHash', () => {
expect(typeof identity.hash()).toBe('number');
});
it('toString returns string', () => {
expect(typeof identity.toString()).toBe('string');
});
it('verify delegates to publicKey', () => {
const msg = randomBytes(10);
const sig = randomBytes(64);
expect(identity.verify(sig, msg)).toBe(identity.publicKey.verify(msg, sig));
});
it('matches works for various types', () => {
expect(identity.matches(identity)).toBe(true);
expect(identity.matches(identity.publicKey)).toBe(true);
expect(identity.matches(identity.publicKey.toBytes())).toBe(true);
expect(identity.matches(identity.publicKey.toString())).toBe(true);
expect(identity.matches(randomBytes(32))).toBe(false);
});
it('constructor throws on invalid type', () => {
expect(() => new (Identity as any)(123)).toThrow();
});
it('matches throws on invalid type', () => {
const pub = randomBytes(32);
const id = new Identity(pub);
expect(() => id.matches({})).toThrow();
});
});
describe('LocalIdentity', () => {
let priv: PrivateKey;
let pub: PublicKey;
let local: LocalIdentity;
beforeEach(() => {
priv = PrivateKey.generate();
pub = priv.toPublicKey();
local = new LocalIdentity(priv, pub);
});
it('constructs from PrivateKey and PublicKey', () => {
expect(local.publicKey.toBytes()).toEqual(pub.toBytes());
});
it('constructs from Uint8Array and string', () => {
const priv2 = PrivateKey.generate();
const pub2 = priv2.toPublicKey();
const local2 = new LocalIdentity(priv2.toBytes(), pub2.toString());
expect(local2.publicKey.toBytes()).toEqual(pub2.toBytes());
});
it('signs message', () => {
const msg = Uint8Array.from([1,2,3,4,5,6,7,8,9,10,11,12]);
const sig = local.sign(msg);
expect(sig).toBeInstanceOf(Uint8Array);
expect(sig.length).toBeGreaterThan(0);
});
it('calculates shared secret with Identity', () => {
const otherPriv = PrivateKey.generate();
const other = new Identity(otherPriv.toPublicKey().toBytes());
const secret = local.calculateSharedSecret(other);
expect(secret).toBeInstanceOf(SharedSecret);
});
it('calculates shared secret with IPublicKey', () => {
const otherPriv = PrivateKey.generate();
const otherPub = otherPriv.toPublicKey();
const secret = local.calculateSharedSecret(otherPub);
expect(secret).toBeInstanceOf(SharedSecret);
});
it('calculateSharedSecret throws on invalid input', () => {
const other: any = {};
expect(() => local.calculateSharedSecret(other)).toThrow();
});
});
describe('Contact', () => {
let priv: PrivateKey;
let pub: PublicKey;
let identity: Identity;
let contact: Contact;
beforeEach(() => {
priv = PrivateKey.generate();
pub = priv.toPublicKey();
identity = new Identity(pub.toBytes());
contact = new Contact('Alice', identity);
});
it('constructs from Identity', () => {
expect(contact.identity).toBe(identity);
expect(contact.name).toBe('Alice');
});
it('constructor throws on invalid type', () => {
expect(() => new (Contact as any)('X', 123)).toThrow();
});
it('constructs from Uint8Array', () => {
const c = new Contact('Bob', pub.toBytes());
expect(c.identity.publicKey.toBytes()).toEqual(pub.toBytes());
});
it('matches works', () => {
expect(contact.matches(pub)).toBe(true);
expect(contact.matches(pub.toBytes())).toBe(true);
expect(contact.matches(new PublicKey(randomBytes(32)))).toBe(false);
});
it('publicKey returns PublicKey', () => {
expect(contact.publicKey()).toBeInstanceOf(PublicKey);
});
it('calculateSharedSecret delegates', () => {
const me = new LocalIdentity(priv, pub);
const secret = contact.calculateSharedSecret(me);
expect(secret).toBeInstanceOf(SharedSecret);
});
});
describe('Group', () => {
let group: Group;
let secret: SharedSecret;
let name: string;
beforeEach(() => {
name = '#test';
secret = SharedSecret.fromName(name);
group = new Group(name, secret);
});
it('constructs with and without secret', () => {
const g1 = new Group(name, secret);
expect(g1).toBeInstanceOf(Group);
const g2 = new Group(name);
expect(g2).toBeInstanceOf(Group);
});
it('hash returns NodeHash', () => {
expect(typeof group.hash()).toBe('number');
});
it('encryptText and decryptText roundtrip', () => {
const plain: DecryptedGroupText = {
timestamp: new Date(),
textType: 1,
attempt: 2,
message: 'hello'
};
const { hmac, ciphertext } = group.encryptText(plain);
const decrypted = group.decryptText(hmac, ciphertext);
expect(decrypted.message).toBe(plain.message);
expect(decrypted.textType).toBe(plain.textType);
expect(decrypted.attempt).toBe(plain.attempt);
expect(typeof decrypted.timestamp).toBe('object');
});
it('encryptData and decryptData roundtrip', () => {
const plain: DecryptedGroupData = {
timestamp: new Date(),
data: randomBytes(10)
};
const { hmac, ciphertext } = group.encryptData(plain);
const decrypted = group.decryptData(hmac, ciphertext);
expect(decrypted.data).toEqual(plain.data);
expect(typeof decrypted.timestamp).toBe('object');
});
it('decryptText throws on short ciphertext', () => {
expect(() => group.decryptText(randomBytes(16), Uint8Array.of(1, 2, 3, 4))).toThrow();
});
it('decryptData throws on short ciphertext', () => {
expect(() => group.decryptData(randomBytes(16), Uint8Array.of(1, 2, 3))).toThrow();
});
});
describe('Contacts', () => {
let contacts: Contacts;
let local: LocalIdentity;
let contact: Contact;
let group: Group;
beforeEach(() => {
contacts = new Contacts();
const priv = PrivateKey.generate();
const pub = priv.toPublicKey();
local = new LocalIdentity(priv, pub);
contact = new Contact('Alice', pub.toBytes());
group = new Group('Public');
});
it('addLocalIdentity and addContact', () => {
contacts.addLocalIdentity(local);
contacts.addContact(contact);
// No error means success
});
it('addGroup', () => {
contacts.addGroup(group);
});
it('decryptGroupText and decryptGroupData', () => {
contacts.addGroup(group);
const text: DecryptedGroupText = {
timestamp: new Date(),
textType: 1,
attempt: 0,
message: 'hi'
};
const { hmac, ciphertext } = group.encryptText(text);
const res = contacts.decryptGroupText(group.hash(), hmac, ciphertext);
expect(res.decrypted.message).toBe(text.message);
const data: DecryptedGroupData = {
timestamp: new Date(),
data: randomBytes(8)
};
const enc = group.encryptData(data);
const res2 = contacts.decryptGroupData(group.hash(), enc.hmac, enc.ciphertext);
expect(res2.decrypted.data).toEqual(data.data);
});
it('decryptGroupText throws on unknown group', () => {
expect(() => contacts.decryptGroupText(0x99, randomBytes(16), randomBytes(16))).toThrow();
});
it('decryptGroupData throws on unknown group', () => {
expect(() => contacts.decryptGroupData(0x99, randomBytes(16), randomBytes(16))).toThrow();
});
it('decrypt throws on unknown source/destination', () => {
contacts.addLocalIdentity(local);
expect(() =>
contacts.decrypt(0x99, 0x99, randomBytes(16), randomBytes(16))
).toThrow();
});
it('decrypt throws on decryption failure', () => {
contacts.addLocalIdentity(local);
contacts.addContact(contact);
expect(() =>
contacts.decrypt(contact.identity.hash(), local.publicKey.key[0], randomBytes(16), randomBytes(16))
).toThrow();
});
it('decrypt works for valid shared secret', () => {
// Setup two identities that can communicate
const privA = PrivateKey.generate();
const pubA = privA.toPublicKey();
const localA = new LocalIdentity(privA, pubA);
const privB = PrivateKey.generate();
const pubB = privB.toPublicKey();
const contactB = new Contact('Bob', pubB);
contacts.addLocalIdentity(localA);
contacts.addContact(contactB);
// Encrypt a message using the shared secret
const shared = localA.calculateSharedSecret(contactB.identity);
const msg = Uint8Array.from([2,3,5,7,11,13,17,19,23,29,31,37]);
const { hmac, ciphertext } = shared.encrypt(msg);
const srcHash = contactB.identity.hash();
const dstHash = localA.publicKey.key[0];
const res = contacts.decrypt(srcHash, dstHash, hmac, ciphertext);
expect(res.decrypted).toEqual(msg);
expect(res.contact.name).toBe('Bob');
expect(res.localIdentity.publicKey.toBytes()).toEqual(pubA.toBytes());
});
it('group decryption falls back when first group fails', () => {
// Setup a group that will succeed and a fake one that throws
const name = '#fallback';
const real = new Group(name);
contacts.addGroup(real);
const hash = real.hash();
const fake = {
name,
decryptText: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); }
} as any;
// Inject fake group before real one
(contacts as any).groups[hash] = [fake, real];
const text = { timestamp: new Date(), textType: 1, attempt: 0, message: 'hi' } as DecryptedGroupText;
const enc = real.encryptText(text);
const res = contacts.decryptGroupText(hash, enc.hmac, enc.ciphertext);
expect(res.decrypted.message).toBe(text.message);
expect(res.group).toBe(real);
});
it('group data decryption falls back when first group fails', () => {
const name = '#fallbackdata';
const real = new Group(name);
contacts.addGroup(real);
const hash = real.hash();
const fake = {
name,
decryptData: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); }
} as any;
(contacts as any).groups[hash] = [fake, real];
const data = { timestamp: new Date(), data: randomBytes(8) } as DecryptedGroupData;
const enc = real.encryptData(data);
const res = contacts.decryptGroupData(hash, enc.hmac, enc.ciphertext);
expect(res.decrypted.data).toEqual(data.data);
expect(res.group).toBe(real);
});
it('decryptText throws when decrypted payload is too short', () => {
const name = '#short';
const secret = SharedSecret.fromName(name);
const group = new Group(name, secret);
// Create ciphertext that decrypts to a payload shorter than 5 bytes
const small = new Uint8Array([1, 2, 3, 4]);
const enc = secret.encrypt(small);
expect(() => group.decryptText(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/);
});
it('decryptData throws when decrypted payload is too short', () => {
const name = '#shortdata';
const secret = SharedSecret.fromName(name);
const group = new Group(name, secret);
const small = new Uint8Array([1, 2, 3]);
const enc = secret.encrypt(small);
expect(() => group.decryptData(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/);
});
it('decrypt throws on unknown destination hash', () => {
contacts.addContact(contact);
expect(() => contacts.decrypt(contact.identity.hash(), 0x99, randomBytes(16), randomBytes(16))).toThrow(/Unknown destination hash/);
});
it('decryptGroupText throws when all groups fail', () => {
const name = '#onlyfail';
const group = new Group(name);
const hash = group.hash();
const fake = { decryptText: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); } } as any;
(contacts as any).groups[hash] = [fake];
expect(() => contacts.decryptGroupText(hash, randomBytes(16), randomBytes(16))).toThrow(/Decryption failed with all known groups/);
});
it('decryptGroupData throws when all groups fail', () => {
const name = '#onlyfaildata';
const group = new Group(name);
const hash = group.hash();
const fake = { decryptData: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); } } as any;
(contacts as any).groups[hash] = [fake];
expect(() => contacts.decryptGroupData(hash, randomBytes(16), randomBytes(16))).toThrow(/Decryption failed with all known groups/);
});
it('decrypt accepts PublicKey as source', () => {
// Setup two identities that can communicate
const privA = PrivateKey.generate();
const pubA = privA.toPublicKey();
const localA = new LocalIdentity(privA, pubA);
const privB = PrivateKey.generate();
const pubB = privB.toPublicKey();
const contactB = new Contact('Bob', pubB);
contacts.addLocalIdentity(localA);
contacts.addContact(contactB);
const shared = localA.calculateSharedSecret(contactB.identity);
const msg = randomBytes(12);
const { hmac, ciphertext } = shared.encrypt(msg);
const res = contacts.decrypt(pubB, localA.publicKey.key[0], hmac, ciphertext);
expect(res.decrypted).toEqual(msg);
expect(res.contact.name).toBe('Bob');
});
it('decrypt with unknown PublicKey creates temporary contact and fails', () => {
contacts.addLocalIdentity(local);
const unknownPub = new PublicKey(randomBytes(32));
expect(() => contacts.decrypt(unknownPub, local.publicKey.key[0], randomBytes(16), randomBytes(16))).toThrow();
});
it('LocalIdentity.calculateSharedSecret handles objects with publicKey variants', () => {
const priv = PrivateKey.generate();
const pub = priv.toPublicKey();
const localId = new LocalIdentity(priv, pub);
const obj1 = { publicKey: pub.toBytes() } as any;
const s1 = localId.calculateSharedSecret(obj1);
expect(s1).toBeInstanceOf(SharedSecret);
const obj2 = { publicKey: pub } as any;
const s2 = localId.calculateSharedSecret(obj2);
expect(s2).toBeInstanceOf(SharedSecret);
const obj3 = { publicKey: () => pub } as any;
const s3 = localId.calculateSharedSecret(obj3);
expect(s3).toBeInstanceOf(SharedSecret);
});
it('decrypt uses cached shared secret on repeated attempts', () => {
// Setup two identities that can communicate
const privA = PrivateKey.generate();
const pubA = privA.toPublicKey();
const localA = new LocalIdentity(privA, pubA);
const privB = PrivateKey.generate();
const pubB = privB.toPublicKey();
const contactB = new Contact('Bob', pubB);
contacts.addLocalIdentity(localA);
contacts.addContact(contactB);
const shared = localA.calculateSharedSecret(contactB.identity);
const msg = randomBytes(12);
const { hmac, ciphertext } = shared.encrypt(msg);
const res1 = contacts.decrypt(contactB.identity.hash(), localA.publicKey.key[0], hmac, ciphertext);
expect(res1.decrypted).toEqual(msg);
// Second call should hit cached shared secret path
const res2 = contacts.decrypt(contactB.identity.hash(), localA.publicKey.key[0], hmac, ciphertext);
expect(res2.decrypted).toEqual(msg);
});
});

233
test/packet.test.ts Normal file
View File

@@ -0,0 +1,233 @@
import { describe, expect, test } from 'vitest';
import { Packet } from '../src/packet';
import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload } from '../src/packet.types';
import { hexToBytes, bytesToHex } from '../src/parser';
describe('Packet.fromBytes', () => {
test('frame 1: len=122 type=5 payload_len=99', () => {
const hex = '1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E';
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(122);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(99);
expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT);
const h = pkt.hash();
expect(h.toUpperCase()).toBe('A17FC3ECD23FCFAD');
});
test('frame 2: len=32 type=1 payload_len=20', () => {
const hex = '050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06';
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(32);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.RESPONSE);
expect(pkt.hash().toUpperCase()).toBe('1D378AD8B7EBA411');
});
test('frame 3: len=38 type=0 payload_len=20', () => {
const hex = '01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185';
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(38);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.REQUEST);
expect(pkt.hash().toUpperCase()).toBe('9948A57E8507EB95');
});
test('frame 4: len=37 type=8 payload_len=20', () => {
const hex = '210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5';
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(37);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.PATH);
expect(pkt.hash().toUpperCase()).toBe('0A5157C46F34ECC1');
});
test('frame 5: len=26 type=3 payload_len=20', () => {
const hex = '2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46';
const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT);
expect(pkt.payloadType).toBe(PayloadType.TRACE);
const payload = pkt.decode();
expect(payload.type).toBe(PayloadType.TRACE);
// the TRACE payload format has been updated; ensure we decode a TRACE payload
expect(payload.type).toBe(PayloadType.TRACE);
// ensure header path bytes were parsed
const expectedHeaderPathHex = '1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220'.toUpperCase();
expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex);
// transport codes (big-endian words as parsed from the packet)
expect(pkt.transport).toEqual([0x42fd, 0x6c4c]);
expect(pkt.pathLength).toBe(0x3b);
// payload bytes check (raw payload must match expected)
const expectedPayloadHex = 'D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex);
// verify decoded trace fields: tag, authCode, flags and nodes
const trace = payload as TracePayload;
// tag/auth are read as little-endian uint32 values (memcpy on little-endian C)
expect(trace.tag).toBe(0xC91F10D2);
expect(trace.authCode).toBe(0xB930CBAC);
// expect(trace.flags).toBe(0x90);
const expectedNodesHex = 'D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex);
});
test('frame 6: len=110 type=1 payload_len=99', () => {
const hex = '1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452';
const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.FLOOD);
expect(pkt.payloadType).toBe(PayloadType.ADVERT);
const adv = pkt.decode() as AdvertPayload;
expect(adv.type).toBe(PayloadType.ADVERT);
const pubHex = 'E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773';
expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex);
// timestamp should match 2024-05-28T22:52:35Z
expect(adv.timestamp.toISOString()).toBe('2024-05-28T22:52:35.000Z');
const sigHex = '3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09';
expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex);
// appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true
expect(adv.appdata.nodeType).toBe(NodeType.REPEATER);
expect(adv.appdata.hasLocation).toBe(true);
expect(adv.appdata.hasName).toBe(true);
// location values: parser appears to scale values by 10 here, accept that
expect(adv.appdata.location).toBeDefined();
expect(adv.appdata.location![0] / 10).toBeCloseTo(51.45986, 5);
expect(adv.appdata.location![1] / 10).toBeCloseTo(5.45422, 5);
expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR');
expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C');
});
});
describe('Packet decode branches and transport/path parsing', () => {
const makePacket = (payloadType: number, routeType: number, pathBytes: Uint8Array, payload: Uint8Array, transportWords?: [number, number]) => {
const header = (0 << 6) | (payloadType << 2) | routeType;
const parts: number[] = [header];
if (transportWords) {
// big-endian uint16 x2
parts.push((transportWords[0] >> 8) & 0xff, transportWords[0] & 0xff);
parts.push((transportWords[1] >> 8) & 0xff, transportWords[1] & 0xff);
}
const pathLength = pathBytes.length;
parts.push(pathLength);
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
arr.set(parts, 0);
arr.set(pathBytes, parts.length);
arr.set(payload, parts.length + pathBytes.length);
return arr;
};
test('hasTransportCodes true/false and transport parsed', () => {
// transport present (route TRANSPORT_FLOOD = 0)
const p = makePacket(PayloadType.REQUEST, RouteType.TRANSPORT_FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]), [0x1122, 0x3344]);
const pkt = Packet.fromBytes(p);
expect(pkt.transport).toEqual([0x1122, 0x3344]);
// no transport (route FLOOD = 1)
const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]));
const pkt2 = Packet.fromBytes(p2);
expect(pkt2.transport).toBeUndefined();
});
test('payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)', () => {
const payload = new Uint8Array([0xAA, 0xBB, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1)
const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload));
const req = pkt.decode() as RequestPayload;
expect(req.type).toBe(PayloadType.REQUEST);
expect(req.dst).toBe(0xAA);
expect(req.src).toBe(0xBB);
const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload;
expect(resp.type).toBe(PayloadType.RESPONSE);
const txt = Packet.fromBytes(makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as TextPayload;
expect(txt.type).toBe(PayloadType.TEXT);
});
test('ACK decode and RAW_CUSTOM', () => {
const ackPayload = new Uint8Array([0x01,0x02,0x03,0x04]);
const ack = Packet.fromBytes(makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)).decode();
expect(ack.type).toBe(PayloadType.ACK);
const custom = new Uint8Array([0x99,0x88,0x77]);
const rc = Packet.fromBytes(makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)).decode() as RawCustomPayload;
expect(rc.type).toBe(PayloadType.RAW_CUSTOM);
expect(rc.data).toEqual(custom);
});
test('ADVERT minimal decode (no appdata extras)', () => {
const publicKey = new Uint8Array(32).fill(1);
const timestamp = new Uint8Array([0x01,0x00,0x00,0x00]);
const signature = new Uint8Array(64).fill(2);
const flags = new Uint8Array([0x00]);
const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]);
const pkt = Packet.fromBytes(makePacket(PayloadType.ADVERT, RouteType.DIRECT, new Uint8Array([]), payload));
const adv = pkt.decode() as AdvertPayload;
expect(adv.type).toBe(PayloadType.ADVERT);
expect(adv.publicKey.length).toBe(32);
expect(adv.signature.length).toBe(64);
expect(adv.appdata.hasName).toBe(false);
});
test('GROUP_TEXT and GROUP_DATA decode', () => {
const payload = new Uint8Array([0x55, 0x01, 0x02, 0x03]); // channelHash + mac(2) + cipher
const gt = Packet.fromBytes(makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
expect(gt.type).toBe(PayloadType.GROUP_TEXT);
const gd = Packet.fromBytes(makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
expect(gd.type).toBe(PayloadType.GROUP_DATA);
});
test('ANON_REQ decode', () => {
const dst = 0x12;
const pub = new Uint8Array(32).fill(3);
const enc = new Uint8Array([0x01,0x02,0x03]);
const payload = new Uint8Array([dst, ...pub, ...enc]);
const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as AnonReqPayload;
expect(ar.type).toBe(PayloadType.ANON_REQ);
expect(ar.dst).toBe(0x12);
});
test('PATH and TRACE decode nodes', () => {
const pathPayload = new Uint8Array([0x0a, 0x0b]);
const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode();
expect(path.type).toBe(PayloadType.PATH);
const nodes = new Uint8Array([0x01,0x02,0x03]);
// construct TRACE payload: tag (4 bytes LE), authCode (4 bytes LE), flags (1), nodes...
const tag = new Uint8Array([0x01,0x00,0x00,0x00]);
const auth = new Uint8Array([0x02,0x00,0x00,0x00]);
const flags = new Uint8Array([0x00]);
const tracePayload = new Uint8Array([...tag, ...auth, ...flags, ...nodes]);
const trace = Packet.fromBytes(makePacket(PayloadType.TRACE, RouteType.DIRECT, new Uint8Array([]), tracePayload)).decode() as TracePayload;
expect(trace.type).toBe(PayloadType.TRACE);
expect(trace.nodes).toBeInstanceOf(Uint8Array);
});
test('pathHashes parsing when multiple hashes', () => {
// create pathLength byte: count=2 size=3 -> (1<<6)|3 = 67
const pathLengthByte = 67;
const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT;
const payload = new Uint8Array([0x01]);
const pathBytes = new Uint8Array([0xAA,0xBB,0xCC, 0x11,0x22,0x33]);
const parts: number[] = [header, pathLengthByte];
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
arr.set(parts, 0);
arr.set(pathBytes, parts.length);
arr.set(payload, parts.length + pathBytes.length);
const pkt = Packet.fromBytes(arr);
expect(pkt.pathHashCount).toBe(2);
expect(pkt.pathHashSize).toBe(3);
expect(pkt.pathHashes.length).toBe(2);
expect(pkt.pathHashes[0]).toBe(bytesToHex(pathBytes.subarray(0,3)));
});
test('unsupported payload type throws', () => {
// payloadType 0x0a is not handled
const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT;
const arr = new Uint8Array([header, 0x00]);
const pkt = Packet.fromBytes(arr);
expect(() => pkt.decode()).toThrow();
});
});

195
test/parser.test.ts Normal file
View File

@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest';
import { base64ToBytes, hexToBytes, BufferReader, BufferWriter } from '../src/parser';
describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8=', 5); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
});
it('handles empty string', () => {
const bytes = base64ToBytes('', 0);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(0);
});
});
describe('BufferReader', () => {
it('readByte and peekByte advance/inspect correctly', () => {
const buf = new Uint8Array([1, 2, 3]);
const r = new BufferReader(buf);
expect(r.peekByte()).toBe(1);
expect(r.readByte()).toBe(1);
expect(r.peekByte()).toBe(2);
});
it('readBytes with and without length', () => {
const buf = new Uint8Array([10, 11, 12, 13]);
const r = new BufferReader(buf);
const a = r.readBytes(2);
expect(Array.from(a)).toEqual([10, 11]);
const b = r.readBytes();
expect(Array.from(b)).toEqual([12, 13]);
});
it('hasMore and remainingBytes reflect position', () => {
const buf = new Uint8Array([5, 6]);
const r = new BufferReader(buf);
expect(r.hasMore()).toBe(true);
expect(r.remainingBytes()).toBe(2);
r.readByte();
expect(r.remainingBytes()).toBe(1);
r.readByte();
expect(r.hasMore()).toBe(false);
});
it('reads little-endian unsigned ints', () => {
const r16 = new BufferReader(new Uint8Array([0x34, 0x12]));
expect(r16.readUint16LE()).toBe(0x1234);
const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]));
expect(r32.readUint32LE()).toBe(0x12345678);
});
it('reads signed ints with two/four bytes (negative)', () => {
const r16 = new BufferReader(new Uint8Array([0xff, 0xff]));
expect(r16.readInt16LE()).toBe(-1);
const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
expect(r32.readInt32LE()).toBe(-1);
});
it('readTimestamp returns Date with seconds->ms conversion', () => {
const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00]));
const d = r.readTimestamp();
expect(d.getTime()).toBe(1000);
});
});
describe('sizedStringToBytes', () => {
it('decodes hex string of correct length', () => {
// 4 bytes = 8 hex chars
const hex = 'deadbeef';
const result = hexToBytes(hex, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('decodes base64 string of correct length', () => {
// 4 bytes = 8 hex chars, base64 for [0xde, 0xad, 0xbe, 0xef] is '3q2+7w=='
const b64 = '3q2+7w==';
const result = base64ToBytes(b64, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('throws on invalid string length', () => {
expect(() => hexToBytes('abc', 4)).toThrow();
expect(() => hexToBytes('deadbeef00', 4)).toThrow();
});
});
describe('BufferWriter', () => {
it('writeByte and toBytes', () => {
const w = new BufferWriter();
w.writeByte(0x12);
w.writeByte(0x34);
expect(Array.from(w.toBytes())).toEqual([0x12, 0x34]);
});
it('writeBytes appends bytes', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2, 3]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3]);
});
it('writeUint16LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint16LE(0x1234);
expect(Array.from(w.toBytes())).toEqual([0x34, 0x12]);
});
it('writeUint32LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint32LE(0x12345678);
expect(Array.from(w.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeInt16LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt16LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt16LE(0x1234);
expect(Array.from(w2.toBytes())).toEqual([0x34, 0x12]);
});
it('writeInt32LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt32LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff, 0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt32LE(0x12345678);
expect(Array.from(w2.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeTimestamp writes seconds as uint32le', () => {
const w = new BufferWriter();
const date = new Date(1000); // 1 second
w.writeTimestamp(date);
expect(Array.from(w.toBytes())).toEqual([0x01, 0x00, 0x00, 0x00]);
});
it('BufferWriter output can be read back by BufferReader', () => {
const w = new BufferWriter();
w.writeByte(0x42);
w.writeUint16LE(0x1234);
w.writeInt16LE(-2);
w.writeUint32LE(0xdeadbeef);
w.writeInt32LE(-123456);
w.writeBytes(new Uint8Array([0x01, 0x02]));
const date = new Date(5000); // 5 seconds
w.writeTimestamp(date);
const bytes = w.toBytes();
const r = new BufferReader(bytes);
expect(r.readByte()).toBe(0x42);
expect(r.readUint16LE()).toBe(0x1234);
expect(r.readInt16LE()).toBe(-2);
expect(r.readUint32LE()).toBe(0xdeadbeef);
expect(r.readInt32LE()).toBe(-123456);
expect(Array.from(r.readBytes(2))).toEqual([0x01, 0x02]);
const readDate = r.readTimestamp();
expect(readDate.getTime()).toBe(5000);
expect(r.hasMore()).toBe(false);
});
it('BufferReader throws or returns undefined if reading past end', () => {
const r = new BufferReader(new Uint8Array([1, 2]));
r.readByte();
r.readByte();
expect(() => r.readByte()).toThrow();
});
it('BufferWriter handles multiple writeBytes calls', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2]));
w.writeBytes(new Uint8Array([3, 4]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3, 4]);
});
it('encodedStringToBytes decodes raw string', () => {
const str = String.fromCharCode(0xde, 0xad, 0xbe, 0xef);
const bytes = new Uint8Array(4);
for (let i = 0; i < 4; i++) bytes[i] = str.charCodeAt(i) & 0xff;
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('hexToBytes returns different length for wrong-size hex', () => {
expect(() => hexToBytes('deadbe', 4)).toThrow();
});
it('base64ToBytes handles URL-safe base64', () => {
// [0xde, 0xad, 0xbe, 0xef] in URL-safe base64: '3q2-7w=='
const bytes = base64ToBytes('3q2-7w==', 4);
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});