Files
Shade/packages/shade-core/tests/events.test.ts
Sterister b014f9b44c feat(observer): M-Obs 1-3 — event bus, server hooks, observer backend
M-Obs 1: Event bus in @shade/core
- ShadeEventEmitter with typed event union, ring buffer for replay
- 12 event types covering session lifecycle, ratchet operations,
  prekey changes, identity rotation, trust changes
- Wired into ShadeSessionManager (zero overhead when not enabled)
- shortHash helper for safe display of public keys
- Security test: regex-checks event payloads contain no key material

M-Obs 2: Prekey server event hooks
- PrekeyServerEvents emitter mirroring core's pattern
- 5 server event types: registered, fetched, replenished, deleted, rate_limited
- Wired into all routes including the rate-limit error handler
- shortHash helper using crypto.subtle directly (no provider dep)

M-Obs 3: @shade/observer package
- StateAggregator subscribes to client + server events, builds rolling snapshot
- Hono routes: GET /api/state (snapshot), GET /api/events (SSE stream)
- Bearer token auth via SHADE_OBSERVER_TOKEN, query string for SSE
- Refuses to start with token < 16 chars (ConfigurationError)
- Static file serving for bundled dashboard at /dashboard/
- Placeholder dashboard renders when no built SPA present

220 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:49:51 +02:00

191 lines
7.0 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import {
ShadeSessionManager,
ShadeEventEmitter,
shortHash,
} from '../src/index.js';
import type { ShadeEvent } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
describe('ShadeEventEmitter', () => {
test('subscribes and emits events', () => {
const emitter = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
emitter.on((e) => received.push(e));
emitter.emit('identity.initialized', { fingerprint: 'abc', registrationId: 1 });
expect(received.length).toBe(1);
expect(received[0]!.name).toBe('identity.initialized');
expect(received[0]!.seq).toBe(1);
expect(received[0]!.timestamp).toBeGreaterThan(0);
expect((received[0]!.data as any).fingerprint).toBe('abc');
});
test('seq is monotonically increasing', () => {
const emitter = new ShadeEventEmitter();
const seqs: number[] = [];
emitter.on((e) => seqs.push(e.seq));
emitter.emit('prekey.generated', { count: 5, totalAfter: 5 });
emitter.emit('prekey.consumed', { keyId: 1 });
emitter.emit('prekey.consumed', { keyId: 2 });
expect(seqs).toEqual([1, 2, 3]);
});
test('unsubscribe stops receiving events', () => {
const emitter = new ShadeEventEmitter();
let count = 0;
const unsub = emitter.on(() => count++);
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
unsub();
emitter.emit('prekey.generated', { count: 1, totalAfter: 2 });
expect(count).toBe(1);
});
test('listener throw does not break other listeners', () => {
const emitter = new ShadeEventEmitter();
let goodCount = 0;
emitter.on(() => { throw new Error('boom'); });
emitter.on(() => goodCount++);
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
expect(goodCount).toBe(1);
});
test('getBufferedSince returns events after seq', () => {
const emitter = new ShadeEventEmitter();
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
emitter.emit('prekey.generated', { count: 1, totalAfter: 2 });
emitter.emit('prekey.generated', { count: 1, totalAfter: 3 });
const events = emitter.getBufferedSince(1);
expect(events.length).toBe(2);
expect(events[0]!.seq).toBe(2);
expect(events[1]!.seq).toBe(3);
});
test('ring buffer evicts oldest', () => {
const emitter = new ShadeEventEmitter({ bufferSize: 3 });
for (let i = 0; i < 5; i++) {
emitter.emit('prekey.generated', { count: 1, totalAfter: i });
}
const recent = emitter.getRecent(10);
expect(recent.length).toBe(3);
expect(recent[0]!.seq).toBe(3);
expect(recent[2]!.seq).toBe(5);
});
});
describe('shortHash helper', () => {
test('produces 16-hex-char string', async () => {
const hash = await shortHash(crypto, crypto.randomBytes(32));
expect(hash).toMatch(/^[0-9a-f]{16}$/);
});
test('deterministic for same input', async () => {
const key = new Uint8Array(32).fill(0xab);
const a = await shortHash(crypto, key);
const b = await shortHash(crypto, key);
expect(a).toBe(b);
});
test('different inputs produce different hashes', async () => {
const a = await shortHash(crypto, crypto.randomBytes(32));
const b = await shortHash(crypto, crypto.randomBytes(32));
expect(a).not.toBe(b);
});
});
describe('ShadeSessionManager event integration', () => {
test('initialize emits identity.initialized', async () => {
const events = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
events.on((e) => received.push(e));
const mgr = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
await mgr.initialize();
const init = received.find((e) => e.name === 'identity.initialized');
expect(init).toBeDefined();
const data = init!.data as any;
expect(data.fingerprint).toMatch(/^\d{5}( \d{5}){11}$/);
expect(data.registrationId).toBeGreaterThan(0);
});
test('full conversation emits expected event sequence', async () => {
const events = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
events.on((e) => received.push(e));
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 env1 = await alice.encrypt('bob', 'hello');
await bob.decrypt('alice', env1);
const env2 = await bob.encrypt('alice', 'hi');
await alice.decrypt('bob', env2);
const names = received.map((e) => e.name);
expect(names).toContain('identity.initialized');
expect(names).toContain('prekey.generated');
expect(names).toContain('session.created');
expect(names).toContain('trust.pinned');
expect(names).toContain('message.encrypted');
expect(names).toContain('message.decrypted');
expect(names).toContain('ratchet.dh_step'); // Bob's reply triggers a DH step
});
test('no events emitted when emitter not provided', async () => {
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
await mgr.initialize();
// No assertion needed — should not throw or error
});
test('SECURITY: no key material in event payloads', async () => {
const events = new ShadeEventEmitter();
const received: ShadeEvent[] = [];
events.on((e) => received.push(e));
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', 'secret message');
await bob.decrypt('alice', env);
await alice.rotateSignedPreKey();
// Serialize all events and check for any 32-byte base64 patterns
// (which would indicate raw key material)
const json = JSON.stringify(received);
// 32-byte base64 = 44 chars (with padding) or 43 (without)
// We allow short 16-hex-char hashes, but no 44-char base64 or 64-char hex
const longBase64 = /[A-Za-z0-9+/]{43,}={0,2}/g;
const longHex = /[0-9a-f]{32,}/gi;
const base64Matches = json.match(longBase64) ?? [];
const hexMatches = json.match(longHex) ?? [];
// Filter out any matches that are inside hash fields (which are 16 hex chars,
// so the regex above wouldn't match anyway, but be explicit)
expect(base64Matches.length).toBe(0);
expect(hexMatches.length).toBe(0);
// Also no plaintext leakage
expect(json).not.toContain('secret message');
});
});