155 lines
6.1 KiB
TypeScript
155 lines
6.1 KiB
TypeScript
|
|
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');
|
||
|
|
});
|
||
|
|
});
|