import { describe, test, expect } from 'bun:test'; import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents, signPayload } from '../src/index.js'; import type { PrekeyServerEvent } from '../src/index.js'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { generateIdentityKeyPair } from '@shade/core'; const crypto = new SubtleCryptoProvider(); function b64(bytes: Uint8Array): string { return Buffer.from(bytes).toString('base64'); } function randBytes(n: number): Uint8Array { const buf = new Uint8Array(n); globalThis.crypto.getRandomValues(buf); return buf; } describe('PrekeyServerEvents integration', () => { test('emits events for register, fetch, replenish, delete', async () => { const events = new PrekeyServerEvents(); const received: PrekeyServerEvent[] = []; events.on((e) => received.push(e)); const store = new MemoryPrekeyStore(); const app = createPrekeyServer({ crypto, store, disableRateLimit: true, events }); const alice = await generateIdentityKeyPair(crypto); // Register const regBody = await signPayload(crypto, alice.signingPrivateKey, { address: 'alice', identitySigningKey: b64(alice.signingPublicKey), identityDHKey: b64(alice.dhPublicKey), signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }], }); await app.request('/v1/keys/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(regBody), }); // Fetch bundle await app.request('/v1/keys/bundle/alice'); // Replenish const replenishBody = await signPayload(crypto, alice.signingPrivateKey, { address: 'alice', oneTimePreKeys: [ { keyId: 200, publicKey: b64(randBytes(32)) }, { keyId: 201, publicKey: b64(randBytes(32)) }, ], }); await app.request('/v1/keys/replenish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(replenishBody), }); // Delete const delBody = await signPayload(crypto, alice.signingPrivateKey, { address: 'alice' }); await app.request('/v1/keys/alice', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(delBody), }); const names = received.map((e) => e.name); expect(names).toContain('server.identity_registered'); expect(names).toContain('server.bundle_fetched'); expect(names).toContain('server.prekeys_replenished'); expect(names).toContain('server.identity_deleted'); // Verify hadOneTimePreKey is true on the fetch event const fetchEvent = received.find((e) => e.name === 'server.bundle_fetched'); expect((fetchEvent!.data as any).hadOneTimePreKey).toBe(true); // Verify replenish reports the right count const replenishEvent = received.find((e) => e.name === 'server.prekeys_replenished'); expect((replenishEvent!.data as any).count).toBe(2); expect((replenishEvent!.data as any).totalAfter).toBe(2); // 1 - 1 (consumed) + 2 = 2 }); test('emits server.rate_limited when limits trip', async () => { const events = new PrekeyServerEvents(); const received: PrekeyServerEvent[] = []; events.on((e) => received.push(e)); // Rate limit ENABLED for this test const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), events }); // Burn the register limit (5/hour) for (let i = 0; i < 7; i++) { const id = await generateIdentityKeyPair(crypto); const body = await signPayload(crypto, id.signingPrivateKey, { address: `user${i}`, identitySigningKey: b64(id.signingPublicKey), identityDHKey: b64(id.dhPublicKey), signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, }); await app.request('/v1/keys/register', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.99' }, body: JSON.stringify(body), }); } const rateLimitedEvents = received.filter((e) => e.name === 'server.rate_limited'); expect(rateLimitedEvents.length).toBeGreaterThan(0); }); test('SECURITY: no key material in server event payloads', async () => { const events = new PrekeyServerEvents(); const received: PrekeyServerEvent[] = []; events.on((e) => received.push(e)); const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events }); const alice = await generateIdentityKeyPair(crypto); const regBody = await signPayload(crypto, alice.signingPrivateKey, { address: 'alice', identitySigningKey: b64(alice.signingPublicKey), identityDHKey: b64(alice.dhPublicKey), signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) }, oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }], }); await app.request('/v1/keys/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(regBody), }); await app.request('/v1/keys/bundle/alice'); const json = JSON.stringify(received); // Same regex as core: no 32+ byte base64 or 32+ char hex const longBase64 = /[A-Za-z0-9+/]{43,}={0,2}/g; const longHex = /[0-9a-f]{32,}/gi; expect(json.match(longBase64) ?? []).toEqual([]); expect(json.match(longHex) ?? []).toEqual([]); }); });