import { describe, expect, it } from "vitest"; import { canonicalJson, computeEventHash, signaturesRepo } from "@eden/database"; const { ENVELOPE_STATUSES, ENVELOPE_TRANSITIONS, assertEnvelopeTransition, InvalidEnvelopeTransitionError } = signaturesRepo; import { generateOtpCode, hashOtpCode } from "../src/signatures/otp.js"; import { generateSignerToken, hashSignerToken } from "../src/signatures/token.js"; describe("hash-chain canonicalization — pure functions, no DB", () => { it("key order never changes the canonical string (defends against Postgres jsonb key reordering)", () => { const a = canonicalJson({ b: 1, a: 2, metadata: { z: 1, y: 2 } }); const b = canonicalJson({ a: 2, b: 1, metadata: { y: 2, z: 1 } }); expect(a).toBe(b); }); it("undefined values canonicalize to null, never dropped", () => { expect(canonicalJson({ a: undefined })).toBe('{"a":null}'); }); it("computeEventHash chains — same inputs always produce the same hash (deterministic, reproducible verification)", () => { const fields = { eventType: "TEST", metadata: { x: 1 } }; const h1 = computeEventHash("", fields); const h2 = computeEventHash("", fields); expect(h1).toBe(h2); expect(h1).toMatch(/^[a-f0-9]{64}$/); }); it("a different previous hash changes the result — this is what makes the chain tamper-evident", () => { const fields = { eventType: "TEST" }; const h1 = computeEventHash("aaa", fields); const h2 = computeEventHash("bbb", fields); expect(h1).not.toBe(h2); }); }); describe("envelope state machine — pure, no DB", () => { it("every terminal status has no outgoing transitions", () => { for (const terminal of ["COMPLETED", "DECLINED", "EXPIRED", "CANCELLED", "SUPERSEDED", "ERROR"] as const) { expect(ENVELOPE_TRANSITIONS[terminal]).toEqual([]); } }); it("SENT -> SIGNED is a valid direct jump (per-signer granularity never mirrored on the envelope)", () => { expect(() => assertEnvelopeTransition("SENT", "SIGNED")).not.toThrow(); }); it("rejects an invalid transition (e.g. DRAFT straight to SIGNED)", () => { expect(() => assertEnvelopeTransition("DRAFT", "SIGNED")).toThrow(InvalidEnvelopeTransitionError); }); it("rejects going backward out of a terminal state", () => { expect(() => assertEnvelopeTransition("COMPLETED", "DRAFT")).toThrow(InvalidEnvelopeTransitionError); }); it("every status in ENVELOPE_STATUSES has an entry in the transition table (no silently-forgotten state)", () => { for (const status of ENVELOPE_STATUSES) { expect(ENVELOPE_TRANSITIONS[status]).toBeDefined(); } }); }); describe("OTP — pure crypto, no DB", () => { it("generateOtpCode always returns 6 digits, zero-padded", () => { for (let i = 0; i < 50; i++) { const code = generateOtpCode(); expect(code).toMatch(/^\d{6}$/); } }); it("hashOtpCode is deterministic for the same code+secret, and never returns the plaintext code", () => { const secret = "a".repeat(32); const h1 = hashOtpCode("123456", secret); const h2 = hashOtpCode("123456", secret); expect(h1).toBe(h2); expect(h1).not.toContain("123456"); expect(h1).toMatch(/^[a-f0-9]{64}$/); }); it("a different secret produces a different hash for the same code (rainbow-table resistance depends on this)", () => { const h1 = hashOtpCode("123456", "a".repeat(32)); const h2 = hashOtpCode("123456", "b".repeat(32)); expect(h1).not.toBe(h2); }); }); describe("secure signer token — pure crypto, no DB", () => { it("generateSignerToken returns 64 hex chars (32 bytes/256 bits)", () => { const token = generateSignerToken(); expect(token).toMatch(/^[a-f0-9]{64}$/); }); it("two generated tokens are never equal", () => { expect(generateSignerToken()).not.toBe(generateSignerToken()); }); it("hashSignerToken is deterministic and never returns the plaintext token", () => { const token = generateSignerToken(); const hash = hashSignerToken(token); expect(hash).not.toBe(token); expect(hashSignerToken(token)).toBe(hash); }); });