import { describe, it, expect } from 'vitest'; import { hashProof, ProofChain, verifyProofChain, generateSpatialProof, } from '../../src/verification/index.js'; import { signFrame, generateNonce } from '../../src/utils/crypto.js'; import type { CameraFrame, RenderedView, Pose, SpatialProof } from '../../src/types/index.js'; const TEST_SECRET = 'a'.repeat(32); const TEST_POSE: Pose = { position: { x: 5, y: 10, z: 0 }, orientation: { w: 1, x: 0, y: 0, z: 0 }, timestamp: Date.now(), }; function makeFrame(width: number, height: number, seed: number = 42): CameraFrame { const rgb = new Uint8Array(width * height * 3); for (let i = 0; i < rgb.length; i++) { rgb[i] = (i * 17 + seed * 31) % 256; } const depth = new Float32Array(width * height); for (let i = 0; i < depth.length; i++) { depth[i] = 1 + Math.random() * 4; } const timestamp = Date.now(); const seq = 1; return { id: generateNonce(8), timestamp, rgb, width, height, depth, pose: TEST_POSE, hmac: signFrame(rgb, timestamp, seq, TEST_SECRET), sequenceNumber: seq, }; } /** Generate a passing proof (same image for expected and actual) */ function makePassingProof( robotId: string, prevProofHash?: string, timestampOffset: number = 0, ): SpatialProof { const frame = makeFrame(64, 64); const render: RenderedView = { rgb: frame.rgb, depth: frame.depth!, width: frame.width, height: frame.height, pose: TEST_POSE, renderTimeMs: 1, }; const proof = generateSpatialProof( robotId, TEST_POSE, frame, render, 'merkle-root', [], TEST_SECRET, ); // Attach prevProofHash (and optionally adjust timestamp) return { ...proof, prevProofHash, timestamp: proof.timestamp + timestampOffset, }; } // ============================================================ // hashProof // ============================================================ describe('hashProof', () => { it('produces a 64-char hex hash (SHA-256)', () => { const proof = makePassingProof('robot-001'); const hash = hashProof(proof); expect(hash).toHaveLength(64); expect(hash).toMatch(/^[a-f0-9]{64}$/); }); it('is deterministic — same proof produces same hash', () => { const proof = makePassingProof('robot-001'); expect(hashProof(proof)).toBe(hashProof(proof)); }); it('different proofs produce different hashes', () => { const a = makePassingProof('robot-001'); const b = makePassingProof('robot-002'); expect(hashProof(a)).not.toBe(hashProof(b)); }); it('includes prevProofHash in the hash computation', () => { const proof = makePassingProof('robot-001'); const withPrev = { ...proof, prevProofHash: 'abc123' }; expect(hashProof(proof)).not.toBe(hashProof(withPrev)); }); }); // ============================================================ // ProofChain — construction and append // ============================================================ describe('ProofChain', () => { it('starts empty', () => { const chain = new ProofChain(); expect(chain.length).toBe(0); expect(chain.tipHash).toBeUndefined(); expect(chain.nextPrevHash).toBeUndefined(); }); it('appends genesis proof (no prevProofHash)', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); expect(chain.length).toBe(1); expect(chain.tipHash).toBe(hashProof(genesis)); }); it('rejects genesis proof that has prevProofHash', () => { const chain = new ProofChain(); const bad = makePassingProof('robot-001', 'should-not-exist'); expect(() => chain.append(bad)).toThrow('Genesis proof'); }); it('appends a second proof linked to genesis', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); const second = makePassingProof('robot-001', chain.nextPrevHash, 100); chain.append(second); expect(chain.length).toBe(2); }); it('rejects a second proof with wrong prevProofHash', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); const bad = makePassingProof('robot-001', 'wrong-hash', 100); expect(() => chain.append(bad)).toThrow('Chain link broken'); }); it('builds a 5-proof chain', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); for (let i = 1; i < 5; i++) { const proof = makePassingProof('robot-001', chain.nextPrevHash, i * 100); chain.append(proof); } expect(chain.length).toBe(5); const verification = chain.verify(); expect(verification.valid).toBe(true); expect(verification.chainLength).toBe(5); expect(verification.chainIntegrity).toBe(1.0); }); it('getProofs returns a defensive copy', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); const proofs = chain.getProofs(); expect(proofs.length).toBe(1); // Mutating the returned array should not affect the chain (proofs as SpatialProof[]).pop(); expect(chain.length).toBe(1); }); it('getProof returns the correct proof by index', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); expect(chain.getProof(0)?.id).toBe(genesis.id); expect(chain.getProof(1)).toBeUndefined(); }); it('can be constructed from existing chain', () => { // Build proofs manually const genesis = makePassingProof('robot-001', undefined); const second = makePassingProof('robot-001', hashProof(genesis), 100); const third = makePassingProof('robot-001', hashProof(second), 200); const chain = new ProofChain([genesis, second, third]); expect(chain.length).toBe(3); const verification = chain.verify(); expect(verification.valid).toBe(true); }); }); // ============================================================ // ProofChain — verification // ============================================================ describe('ProofChain.verify', () => { it('empty chain is valid', () => { const chain = new ProofChain(); const result = chain.verify(); expect(result.valid).toBe(true); expect(result.chainLength).toBe(0); expect(result.chainIntegrity).toBe(1.0); expect(result.brokenLinks).toEqual([]); }); it('single genesis proof is valid', () => { const chain = new ProofChain(); chain.append(makePassingProof('robot-001', undefined)); const result = chain.verify(); expect(result.valid).toBe(true); expect(result.chainLength).toBe(1); expect(result.chainIntegrity).toBe(1.0); }); it('detects tampered proof in middle of chain', () => { const genesis = makePassingProof('robot-001', undefined); const second = makePassingProof('robot-001', hashProof(genesis), 100); const third = makePassingProof('robot-001', hashProof(second), 200); // Tamper with the second proof's nonce (changes its hash) const tampered = { ...second, nonce: 'tampered-nonce' }; // third still has the hash of the original second, not the tampered one const chain = new ProofChain([genesis, tampered, third], { skipValidate: true }); const result = chain.verify(); expect(result.valid).toBe(false); expect(result.brokenLinks).toContain(2); // third points to old hash expect(result.chainIntegrity).toBeLessThan(1.0); expect(result.reason).toContain('broken link'); }); it('detects genesis with unexpected prevProofHash', () => { const genesis = makePassingProof('robot-001', undefined); const badGenesis = { ...genesis, prevProofHash: 'unexpected' }; const chain = new ProofChain([badGenesis], { skipValidate: true }); const result = chain.verify(); expect(result.valid).toBe(false); expect(result.brokenLinks).toContain(0); }); it('detects out-of-order timestamps', () => { const genesis = makePassingProof('robot-001', undefined); // Second proof has timestamp BEFORE genesis const second = makePassingProof('robot-001', hashProof(genesis), -100000); const chain = new ProofChain([genesis, second], { skipValidate: true }); const result = chain.verify(); expect(result.valid).toBe(false); expect(result.brokenLinks).toContain(1); }); it('detects mismatched robotId', () => { const genesis = makePassingProof('robot-001', undefined); const second = makePassingProof('robot-002', hashProof(genesis), 100); const chain = new ProofChain([genesis, second], { skipValidate: true }); const result = chain.verify(); expect(result.valid).toBe(false); expect(result.brokenLinks).toContain(1); }); it('chainIntegrity reflects partial validity', () => { const genesis = makePassingProof('robot-001', undefined); const second = makePassingProof('robot-001', hashProof(genesis), 100); // Third has wrong prevProofHash const third = makePassingProof('robot-001', 'wrong', 200); // Fourth is properly linked to third (even though third is broken) const fourth = makePassingProof('robot-001', hashProof(third), 300); const chain = new ProofChain([genesis, second, third, fourth], { skipValidate: true }); const result = chain.verify(); expect(result.valid).toBe(false); expect(result.chainIntegrity).toBe(0.75); // 3 of 4 links valid expect(result.brokenLinks).toEqual([2]); }); }); // ============================================================ // ProofChain.verifyRange // ============================================================ describe('ProofChain.verifyRange', () => { it('verifies a sub-range of the chain', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); for (let i = 1; i < 5; i++) { chain.append(makePassingProof('robot-001', chain.nextPrevHash, i * 100)); } const result = chain.verifyRange(1, 4); expect(result.valid).toBe(true); expect(result.chainLength).toBe(3); }); it('throws on invalid range', () => { const chain = new ProofChain(); chain.append(makePassingProof('robot-001', undefined)); expect(() => chain.verifyRange(-1, 1)).toThrow('Invalid range'); expect(() => chain.verifyRange(0, 5)).toThrow('Invalid range'); expect(() => chain.verifyRange(1, 0)).toThrow('Invalid range'); }); }); // ============================================================ // ProofChain.exportHashChain // ============================================================ describe('ProofChain.exportHashChain', () => { it('returns array of hashes matching chain length', () => { const chain = new ProofChain(); const genesis = makePassingProof('robot-001', undefined); chain.append(genesis); chain.append(makePassingProof('robot-001', chain.nextPrevHash, 100)); chain.append(makePassingProof('robot-001', chain.nextPrevHash, 200)); const hashes = chain.exportHashChain(); expect(hashes.length).toBe(3); for (const h of hashes) { expect(h).toMatch(/^[a-f0-9]{64}$/); } }); it('empty chain returns empty array', () => { const chain = new ProofChain(); expect(chain.exportHashChain()).toEqual([]); }); }); // ============================================================ // verifyProofChain (standalone function) // ============================================================ describe('verifyProofChain', () => { it('validates a correct proof array', () => { const genesis = makePassingProof('robot-001', undefined); const second = makePassingProof('robot-001', hashProof(genesis), 100); const third = makePassingProof('robot-001', hashProof(second), 200); const result = verifyProofChain([genesis, second, third]); expect(result.valid).toBe(true); expect(result.chainLength).toBe(3); expect(result.chainIntegrity).toBe(1.0); }); it('validates empty array', () => { const result = verifyProofChain([]); expect(result.valid).toBe(true); expect(result.chainLength).toBe(0); }); it('detects broken chain in array form', () => { const genesis = makePassingProof('robot-001', undefined); const broken = makePassingProof('robot-001', 'wrong-hash', 100); const result = verifyProofChain([genesis, broken]); expect(result.valid).toBe(false); expect(result.brokenLinks).toContain(1); }); });