import { describe, it, expect } from 'vitest'; import { computeEventHash, computeEventHashRaw, verifyChain, verifyChainBatch, verifyRecords, HASH_VERSION } from '../hash.js'; import type { HashableEvent, ChainEvent, RawHashableEvent } from '../hash.js'; describe('Story 2.4: Hash Chain Utilities', () => { const baseEvent: HashableEvent = { id: '01HXY0001', timestamp: '2026-02-07T10:00:00Z', sessionId: 'sess_abc123', agentId: 'agent_main', eventType: 'tool_call', severity: 'info', payload: { toolName: 'web_search', arguments: { query: 'test' }, callId: 'call_1', }, metadata: {}, prevHash: null, }; describe('HASH_VERSION', () => { it('should be 2', () => { expect(HASH_VERSION).toBe(2); }); }); describe('computeEventHash()', () => { it('should return a deterministic SHA-256 hex string', () => { const hash = computeEventHash(baseEvent); expect(hash).toMatch(/^[0-9a-f]{64}$/); }); it('should return identical hash for the same input (deterministic)', () => { const hash1 = computeEventHash(baseEvent); const hash2 = computeEventHash(baseEvent); expect(hash1).toBe(hash2); }); it('should return different hash for different payload', () => { const event2: HashableEvent = { ...baseEvent, payload: { toolName: 'file_read', arguments: { path: '/tmp' }, callId: 'call_2', }, }; const hash1 = computeEventHash(baseEvent); const hash2 = computeEventHash(event2); expect(hash1).not.toBe(hash2); }); it('should return different hash for different prevHash', () => { const event2: HashableEvent = { ...baseEvent, prevHash: 'abc123', }; const hash1 = computeEventHash(baseEvent); const hash2 = computeEventHash(event2); expect(hash1).not.toBe(hash2); }); it('should handle first event with prevHash = null', () => { const event: HashableEvent = { ...baseEvent, prevHash: null, }; const hash = computeEventHash(event); expect(hash).toMatch(/^[0-9a-f]{64}$/); }); it('should return different hash for different id', () => { const event2: HashableEvent = { ...baseEvent, id: '01HXY0002', }; expect(computeEventHash(baseEvent)).not.toBe(computeEventHash(event2)); }); it('should return different hash for different timestamp', () => { const event2: HashableEvent = { ...baseEvent, timestamp: '2026-02-07T10:00:01Z', }; expect(computeEventHash(baseEvent)).not.toBe(computeEventHash(event2)); }); it('should return different hash for different sessionId', () => { const event2: HashableEvent = { ...baseEvent, sessionId: 'sess_other', }; expect(computeEventHash(baseEvent)).not.toBe(computeEventHash(event2)); }); it('should return different hash for different agentId', () => { const event2: HashableEvent = { ...baseEvent, agentId: 'agent_other', }; expect(computeEventHash(baseEvent)).not.toBe(computeEventHash(event2)); }); it('should return different hash for different eventType', () => { const event2: HashableEvent = { ...baseEvent, eventType: 'tool_response', }; expect(computeEventHash(baseEvent)).not.toBe(computeEventHash(event2)); }); it('should return different hash for different severity', () => { const event2: HashableEvent = { ...baseEvent, severity: 'error', }; expect(computeEventHash(baseEvent)).not.toBe(computeEventHash(event2)); }); it('should return different hash for different metadata', () => { const event2: HashableEvent = { ...baseEvent, metadata: { source: 'mcp' }, }; expect(computeEventHash(baseEvent)).not.toBe(computeEventHash(event2)); }); }); describe('verifyChain()', () => { /** Helper to build a ChainEvent from a HashableEvent */ function toChainEvent(event: HashableEvent): ChainEvent { const hash = computeEventHash(event); return { ...event, hash }; } it('should return valid for an empty chain', () => { const result = verifyChain([]); expect(result.valid).toBe(true); expect(result.failedAtIndex).toBe(-1); expect(result.reason).toBeNull(); }); it('should return valid for a single event with prevHash = null', () => { const chainEvent = toChainEvent(baseEvent); const result = verifyChain([chainEvent]); expect(result.valid).toBe(true); }); it('should return valid for a valid chain of events', () => { // Build a chain of 3 events const event1: HashableEvent = { id: '01HXY0001', timestamp: '2026-02-07T10:00:00Z', sessionId: 'sess_1', agentId: 'agent_1', eventType: 'session_started', severity: 'info', payload: { agentName: 'Test Agent' }, metadata: {}, prevHash: null, }; const ce1 = toChainEvent(event1); const event2: HashableEvent = { id: '01HXY0002', timestamp: '2026-02-07T10:00:01Z', sessionId: 'sess_1', agentId: 'agent_1', eventType: 'tool_call', severity: 'info', payload: { toolName: 'search', arguments: {}, callId: 'c1' }, metadata: {}, prevHash: ce1.hash, }; const ce2 = toChainEvent(event2); const event3: HashableEvent = { id: '01HXY0003', timestamp: '2026-02-07T10:00:02Z', sessionId: 'sess_1', agentId: 'agent_1', eventType: 'tool_response', severity: 'info', payload: { callId: 'c1', toolName: 'search', result: {}, durationMs: 50 }, metadata: {}, prevHash: ce2.hash, }; const ce3 = toChainEvent(event3); const result = verifyChain([ce1, ce2, ce3]); expect(result.valid).toBe(true); }); it('should detect tampered event hash (payload modified after hashing)', () => { const event1: HashableEvent = { id: '01HXY0001', timestamp: '2026-02-07T10:00:00Z', sessionId: 'sess_1', agentId: 'agent_1', eventType: 'session_started', severity: 'info', payload: { agentName: 'Test Agent' }, metadata: {}, prevHash: null, }; const ce1 = toChainEvent(event1); const event2: HashableEvent = { id: '01HXY0002', timestamp: '2026-02-07T10:00:01Z', sessionId: 'sess_1', agentId: 'agent_1', eventType: 'tool_call', severity: 'info', payload: { toolName: 'search', arguments: {}, callId: 'c1' }, metadata: {}, prevHash: ce1.hash, }; const ce2 = toChainEvent(event2); // Tamper: change payload but keep the old hash const tampered: ChainEvent = { ...ce2, payload: { toolName: 'HACKED', arguments: {}, callId: 'c1' }, }; const result = verifyChain([ce1, tampered]); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(1); expect(result.reason).toContain('hash mismatch'); }); it('should detect tampered severity (severity modified after hashing)', () => { const ce1 = toChainEvent(baseEvent); // Tamper: change severity but keep old hash const tampered: ChainEvent = { ...ce1, severity: 'critical' }; const result = verifyChain([tampered]); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(0); expect(result.reason).toContain('hash mismatch'); }); it('should detect tampered metadata', () => { const ce1 = toChainEvent(baseEvent); // Tamper: change metadata but keep old hash const tampered: ChainEvent = { ...ce1, metadata: { injected: true } }; const result = verifyChain([tampered]); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(0); expect(result.reason).toContain('hash mismatch'); }); it('should return false when prevHash does not match previous hash', () => { const ce1 = toChainEvent(baseEvent); const event2: HashableEvent = { ...baseEvent, id: '01HXY0002', prevHash: 'wrong_hash', }; const ce2: ChainEvent = { ...event2, hash: computeEventHash(event2), }; const result = verifyChain([ce1, ce2]); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(1); expect(result.reason).toContain('prevHash does not match'); }); it('should return false when first event has non-null prevHash', () => { const event: HashableEvent = { ...baseEvent, prevHash: 'should_be_null' }; const ce: ChainEvent = { ...event, hash: computeEventHash(event) }; const result = verifyChain([ce]); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(0); expect(result.reason).toContain('First event must have prevHash = null'); }); it('should return valid for a long valid chain', () => { const chain: ChainEvent[] = []; let prevHash: string | null = null; for (let i = 0; i < 100; i++) { const event: HashableEvent = { id: `01HXY${String(i).padStart(4, '0')}`, timestamp: `2026-02-07T10:00:${String(i).padStart(2, '0')}Z`, sessionId: 'sess_1', agentId: 'agent_1', eventType: 'custom', severity: 'info', payload: { type: 'test', data: { index: i } }, metadata: {}, prevHash, }; const hash = computeEventHash(event); chain.push({ ...event, hash }); prevHash = hash; } const result = verifyChain(chain); expect(result.valid).toBe(true); }); it('should detect tampering in the middle of a long chain', () => { const chain: ChainEvent[] = []; let prevHash: string | null = null; for (let i = 0; i < 10; i++) { const event: HashableEvent = { id: `01HXY${String(i).padStart(4, '0')}`, timestamp: `2026-02-07T10:00:${String(i).padStart(2, '0')}Z`, sessionId: 'sess_1', agentId: 'agent_1', eventType: 'custom', severity: 'info', payload: { type: 'test', data: { index: i } }, metadata: {}, prevHash, }; const hash = computeEventHash(event); chain.push({ ...event, hash }); prevHash = hash; } // Tamper event at index 5: change the hash (simulates payload modification) chain[5] = { ...chain[5], hash: 'tampered_hash' }; const result = verifyChain(chain); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(5); }); it('should provide detailed failure info', () => { const ce1 = toChainEvent(baseEvent); // Event with wrong hash const event2: HashableEvent = { ...baseEvent, id: '01HXY0002', prevHash: ce1.hash, }; const ce2: ChainEvent = { ...event2, hash: 'clearly_wrong_hash', }; const result = verifyChain([ce1, ce2]); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(1); expect(result.reason).toContain('Event 1'); expect(result.reason).toContain('hash mismatch'); }); }); describe('verifyRecords() — unchained record integrity', () => { function toChainEvent(event: HashableEvent): ChainEvent { return { ...event, hash: computeEventHash(event) }; } it('accepts unchained events (all prevHash=null) that a strict chain rejects', () => { const evs = ['a', 'b', 'c'].map((s, i) => toChainEvent({ ...baseEvent, id: `01HXY000${i}`, prevHash: null, payload: { agentName: s } }), ); // No cross-event linkage — every event stands alone. expect(verifyRecords(evs).valid).toBe(true); // The strict chain (correctly) rejects: event 1's prevHash=null ≠ event 0's hash. expect(verifyChain(evs).valid).toBe(false); }); it('detects a tampered record', () => { const evs = [0, 1].map((i) => toChainEvent({ ...baseEvent, id: `01HXY00${i}`, prevHash: null })); const tampered: ChainEvent = { ...evs[1]!, payload: { agentName: 'mutated' } }; // hash now stale const result = verifyRecords([evs[0]!, tampered]); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(1); }); it('returns valid for an empty set', () => { expect(verifyRecords([]).valid).toBe(true); }); }); describe('computeEventHashRaw()', () => { it('AC 7.1 — produces identical hash to computeEventHash() for varied events', () => { const events: HashableEvent[] = []; let prevHash: string | null = null; for (let i = 0; i < 100; i++) { const event: HashableEvent = { id: `evt_${i}`, timestamp: `2026-02-07T10:00:${String(i % 60).padStart(2, '0')}Z`, sessionId: 'sess_1', agentId: 'agent_1', eventType: i % 2 === 0 ? 'tool_call' : 'custom', severity: i % 3 === 0 ? 'error' : 'info', payload: { index: i, nested: { key: `value_${i}` }, list: [1, 2, 3] }, metadata: i % 2 === 0 ? {} : { source: 'test', count: i }, prevHash, }; events.push(event); prevHash = computeEventHash(event); } for (const event of events) { const rawEvent: RawHashableEvent = { id: event.id, timestamp: event.timestamp, sessionId: event.sessionId, agentId: event.agentId, eventType: event.eventType, severity: event.severity, payloadRaw: JSON.stringify(event.payload), metadataRaw: JSON.stringify(event.metadata), prevHash: event.prevHash, }; expect(computeEventHashRaw(rawEvent)).toBe(computeEventHash(event)); } }); it('handles null prevHash correctly', () => { const raw: RawHashableEvent = { id: 'evt_0', timestamp: '2026-02-07T10:00:00Z', sessionId: 'sess_1', agentId: 'agent_1', eventType: 'custom', severity: 'info', payloadRaw: '{}', metadataRaw: '{}', prevHash: null, }; const normal: HashableEvent = { ...raw, payload: {}, metadata: {}, }; expect(computeEventHashRaw(raw)).toBe(computeEventHash(normal)); }); }); describe('verifyChainBatch()', () => { /** Helper to build a valid chain of N events */ function buildChain(n: number, sessionId = 'sess_1'): ChainEvent[] { const chain: ChainEvent[] = []; let prevHash: string | null = null; for (let i = 0; i < n; i++) { const event: HashableEvent = { id: `evt_${sessionId}_${String(i).padStart(4, '0')}`, timestamp: `2026-02-07T10:00:${String(i).padStart(2, '0')}Z`, sessionId, agentId: 'agent_1', eventType: 'custom', severity: 'info', payload: { index: i }, metadata: {}, prevHash, }; const hash = computeEventHash(event); chain.push({ ...event, hash }); prevHash = hash; } return chain; } it('AC 1.1 — valid chain continuation', () => { const chain = buildChain(10); // Simulate batch 2: events 5-9 continuing from event 4 const batch = chain.slice(5); const result = verifyChainBatch(batch, chain[4].hash); expect(result.valid).toBe(true); expect(result.failedAtIndex).toBe(-1); expect(result.reason).toBeNull(); }); it('AC 1.2 — genesis batch (null prevHash)', () => { const chain = buildChain(5); const result = verifyChainBatch(chain, null); expect(result.valid).toBe(true); }); it('AC 1.3 — broken linkage between batches', () => { const chain = buildChain(10); const batch = chain.slice(5); const result = verifyChainBatch(batch, 'wrong_prev_hash'); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(0); expect(result.reason).toContain('Batch linkage broken'); }); it('AC 1.4 — tampered event hash', () => { const chain = buildChain(10); // Tamper event at index 7 chain[7] = { ...chain[7], payload: { index: 999 } }; const batch = chain.slice(5); const result = verifyChainBatch(batch, chain[4].hash); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(2); // index 7 in chain = index 2 in batch expect(result.reason).toContain('hash mismatch'); }); it('AC 1.5 — empty batch', () => { const result = verifyChainBatch([], null); expect(result.valid).toBe(true); expect(result.failedAtIndex).toBe(-1); expect(result.reason).toBeNull(); }); it('AC 1.6 — backward compatibility with verifyChain()', () => { const chain = buildChain(20); const resultOld = verifyChain(chain); const resultNew = verifyChainBatch(chain, null); expect(resultOld).toEqual(resultNew); // Also test with invalid chain chain[10] = { ...chain[10], payload: { index: 999 } }; const resultOldBad = verifyChain(chain); const resultNewBad = verifyChainBatch(chain, null); expect(resultOldBad).toEqual(resultNewBad); }); it('should detect first event with non-null prevHash when expectedPrevHash is null', () => { const chain = buildChain(5); // Take batch starting from index 3 (prevHash != null) but pass null const batch = chain.slice(3); const result = verifyChainBatch(batch, null); expect(result.valid).toBe(false); expect(result.failedAtIndex).toBe(0); expect(result.reason).toContain('First event must have prevHash = null'); }); }); });