import { describe, test, expect } from 'bun:test'; import { createObserver, StateAggregator } from '../src/index.js'; import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; import { ShadeSessionManager, ShadeEventEmitter } from '@shade/core'; import { PrekeyServerEvents } from '@shade/server'; const crypto = new SubtleCryptoProvider(); const TEST_TOKEN = 'test-token-must-be-at-least-16-chars'; describe('StateAggregator', () => { test('aggregates client events into snapshot', async () => { const events = new ShadeEventEmitter(); const agg = new StateAggregator(events); const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); await alice.initialize(); await alice.generateOneTimePreKeys(10); const snap = agg.snapshot(); expect(snap.identity.fingerprint).toBeTruthy(); expect(snap.identity.registrationId).toBeGreaterThan(0); expect(snap.prekeys.oneTimeRemaining).toBe(10); expect(snap.prekeys.lastGenerated).toBeGreaterThan(0); }); test('tracks sessions across encrypt/decrypt', async () => { const events = new ShadeEventEmitter(); const agg = new StateAggregator(events); const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); await alice.initialize(); await bob.initialize(); 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('bob', bundle); const env = await alice.encrypt('bob', 'hello'); await bob.decrypt('alice', env); const snap = agg.snapshot(); const aliceToBob = snap.sessions.find((s) => s.address === 'bob'); expect(aliceToBob).toBeDefined(); expect(aliceToBob!.messageCountSent).toBe(1); }); test('tracks server events', () => { const serverEvents = new PrekeyServerEvents(); const agg = new StateAggregator(undefined, serverEvents); serverEvents.emit('server.identity_registered', { address: 'alice', identityKeyHash: 'abc' }); serverEvents.emit('server.identity_registered', { address: 'bob', identityKeyHash: 'def' }); serverEvents.emit('server.bundle_fetched', { address: 'alice', hadOneTimePreKey: true }); serverEvents.emit('server.bundle_fetched', { address: 'alice', hadOneTimePreKey: false }); serverEvents.emit('server.identity_deleted', { address: 'alice' }); const snap = agg.snapshot(); expect(snap.server.registeredIdentities.has('bob')).toBe(true); expect(snap.server.registeredIdentities.has('alice')).toBe(false); expect(snap.server.totalBundleFetches).toBe(2); expect(snap.server.totalDeleted).toBe(1); }); }); describe('Observer routes', () => { test('refuses requests without token', async () => { const events = new ShadeEventEmitter(); const observer = createObserver({ token: TEST_TOKEN, clientEvents: events }); const res = await observer.request('/api/state'); expect(res.status).toBe(401); }); test('accepts requests with valid bearer token', async () => { const events = new ShadeEventEmitter(); const observer = createObserver({ token: TEST_TOKEN, clientEvents: events }); const mgr = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); await mgr.initialize(); const res = await observer.request('/api/state', { headers: { Authorization: `Bearer ${TEST_TOKEN}` }, }); expect(res.status).toBe(200); const body = await res.json(); expect(body.identity.fingerprint).toBeTruthy(); }); test('refuses requests with wrong token', async () => { const events = new ShadeEventEmitter(); const observer = createObserver({ token: TEST_TOKEN, clientEvents: events }); const res = await observer.request('/api/state', { headers: { Authorization: 'Bearer wrong-token-also-long-enough' }, }); expect(res.status).toBe(401); }); test('accepts token via query string for SSE', async () => { const events = new ShadeEventEmitter(); const observer = createObserver({ token: TEST_TOKEN, clientEvents: events }); // Just check that the auth middleware accepts the query token const res = await observer.request(`/api/state?token=${TEST_TOKEN}`); expect(res.status).toBe(200); }); test('refuses startup with too-short token', () => { expect(() => createObserver({ token: 'short' })).toThrow(); }); test('health endpoint works without auth', async () => { const observer = createObserver({ token: TEST_TOKEN }); const res = await observer.request('/health'); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe('ok'); }); test('snapshot reflects state after operations', async () => { const events = new ShadeEventEmitter(); const observer = createObserver({ token: TEST_TOKEN, clientEvents: events }); const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); await alice.initialize(); await bob.initialize(); const otpks = await bob.generateOneTimePreKeys(3); const bundle = await bob.createPreKeyBundle(); bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; await alice.initSessionFromBundle('bob', bundle); const env = await alice.encrypt('bob', 'hi'); await bob.decrypt('alice', env); const res = await observer.request('/api/state', { headers: { Authorization: `Bearer ${TEST_TOKEN}` }, }); const body = await res.json(); expect(body.sessions.length).toBeGreaterThan(0); // Bob started with 3 OTPKs; Alice consumed one via X3DH PreKeyMessage decrypt expect(body.prekeys.oneTimeRemaining).toBe(2); }); test('placeholder dashboard renders when no dist', async () => { const observer = createObserver({ token: TEST_TOKEN }); const res = await observer.request('/dashboard/'); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('Shade Observer'); }); });