import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; import { ShadeSessionManager } from '../src/index.js'; import { DeviceListManager, fanOutEncrypt, observeIncoming, userOfDevice, deviceIdOf, } from '../src/sesame.js'; const crypto = new SubtleCryptoProvider(); describe('DeviceListManager', () => { test('add and get devices', () => { const mgr = new DeviceListManager(); mgr.addDevice('bob', 'bob:phone'); mgr.addDevice('bob', 'bob:laptop'); mgr.addDevice('bob', 'bob:tablet'); const devices = mgr.getDevices('bob'); expect(devices.length).toBe(3); expect(devices).toContain('bob:phone'); expect(devices).toContain('bob:laptop'); expect(devices).toContain('bob:tablet'); }); test('remove device', () => { const mgr = new DeviceListManager(); mgr.addDevice('bob', 'bob:phone'); mgr.addDevice('bob', 'bob:laptop'); mgr.removeDevice('bob', 'bob:phone'); expect(mgr.getDevices('bob')).toEqual(['bob:laptop']); }); test('setDevices replaces list', () => { const mgr = new DeviceListManager(); mgr.addDevice('bob', 'bob:phone'); mgr.setDevices('bob', ['bob:laptop', 'bob:tablet']); expect(mgr.getDevices('bob').sort()).toEqual(['bob:laptop', 'bob:tablet']); }); test('empty list for unknown user', () => { const mgr = new DeviceListManager(); expect(mgr.getDevices('nobody')).toEqual([]); }); test('toJSON and fromJSON roundtrip', () => { const mgr = new DeviceListManager(); mgr.addDevice('bob', 'bob:phone'); mgr.addDevice('alice', 'alice:laptop'); const json = mgr.toJSON(); const restored = new DeviceListManager(); restored.fromJSON(json); expect(restored.getDevices('bob')).toEqual(['bob:phone']); expect(restored.getDevices('alice')).toEqual(['alice:laptop']); }); }); describe('Address parsing helpers', () => { test('userOfDevice extracts the user part', () => { expect(userOfDevice('bob:phone')).toBe('bob'); expect(userOfDevice('alice@example.com:tablet')).toBe('alice@example.com'); expect(userOfDevice('noColon')).toBe('noColon'); }); test('deviceIdOf extracts the device part', () => { expect(deviceIdOf('bob:phone')).toBe('phone'); expect(deviceIdOf('alice@example.com:tablet')).toBe('tablet'); expect(deviceIdOf('noColon')).toBe(''); }); }); describe('observeIncoming', () => { test('auto-registers a new device', () => { const mgr = new DeviceListManager(); observeIncoming(mgr, 'bob:newPhone'); expect(mgr.getDevices('bob')).toEqual(['bob:newPhone']); }); test('second message from same device is idempotent', () => { const mgr = new DeviceListManager(); observeIncoming(mgr, 'bob:phone'); observeIncoming(mgr, 'bob:phone'); expect(mgr.getDevices('bob').length).toBe(1); }); }); describe('fanOutEncrypt: multi-device fan-out', () => { async function setupAliceToBobDevices(devices: string[]) { // Alice has one SessionManager const alice = new ShadeSessionManager(crypto, new MemoryStorage()); await alice.initialize(); // Each Bob device has its own SessionManager (separate storage) const bobs = new Map(); for (const device of devices) { const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); await mgr.initialize(); bobs.set(device, mgr); } // Alice establishes a session with each Bob device for (const [device, bob] of bobs) { const otpks = await bob.generateOneTimePreKeys(5); const bundle = await bob.createPreKeyBundle(); bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; await alice.initSessionFromBundle(device, bundle); } // Alice tracks Bob's device list const deviceList = new DeviceListManager(); for (const device of devices) { deviceList.addDevice('bob', device); } return { alice, bobs, deviceList }; } test('fans out to all devices', async () => { const devices = ['bob:phone', 'bob:laptop', 'bob:tablet']; const { alice, bobs, deviceList } = await setupAliceToBobDevices(devices); const fanOut = await fanOutEncrypt(alice, deviceList, 'bob', 'hello all my devices'); expect(fanOut.length).toBe(3); for (const { deviceAddress, envelope } of fanOut) { const bob = bobs.get(deviceAddress)!; const plain = await bob.decrypt('alice', envelope); expect(plain).toBe('hello all my devices'); } }); test('each device gets an independent session (DH ratchet per device)', async () => { const devices = ['bob:phone', 'bob:laptop']; const { alice, bobs, deviceList } = await setupAliceToBobDevices(devices); // Send two rounds for (let i = 0; i < 2; i++) { const fanOut = await fanOutEncrypt(alice, deviceList, 'bob', `round ${i}`); for (const { deviceAddress, envelope } of fanOut) { const bob = bobs.get(deviceAddress)!; const plain = await bob.decrypt('alice', envelope); expect(plain).toBe(`round ${i}`); } } }); test('throws when user has no known devices', async () => { const alice = new ShadeSessionManager(crypto, new MemoryStorage()); await alice.initialize(); const deviceList = new DeviceListManager(); expect( fanOutEncrypt(alice, deviceList, 'unknown-user', 'hello'), ).rejects.toThrow(/No known devices/); }); });