// tools/receipt-sign.test.mjs import { describe, it, expect } from 'vitest'; import { canonicalJson, signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'; describe('доменное разделение подписи (R-31)', () => { const KEY = 'test-secret-key-123'; it('подпись одного домена НЕ проходит проверку другого домена', () => { const rec = { a: 1 }; const sig = signPayload(rec, KEY, RECEIPT_DOMAINS.JOURNAL_HEAD); expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.JOURNAL_HEAD)).toBe(true); expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.FROZEN_PLAN)).toBe(false); }); it('RECEIPT_DOMAINS экспортирует различимые домены', () => { expect(typeof RECEIPT_DOMAINS.FROZEN_PLAN).toBe('string'); expect(RECEIPT_DOMAINS.FROZEN_ARTIFACT).not.toBe(RECEIPT_DOMAINS.FROZEN_PLAN); }); it('без домена (default) — обратная совместимость сохранена', () => { const sig = signPayload({ a: 1 }, KEY); expect(verifyReceipt({ a: 1, sig }, KEY)).toBe(true); }); }); describe('receipt-sign: домен M5_GREEN (Пакет 3.1)', () => { const KEY = 'test-secret-key-123'; it('RECEIPT_DOMAINS.M5_GREEN — отдельный строковый домен m5-green', () => { expect(typeof RECEIPT_DOMAINS.M5_GREEN).toBe('string'); expect(RECEIPT_DOMAINS.M5_GREEN).toBe('m5-green'); }); it('M5_GREEN отличается от всех прочих доменов', () => { expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.JOURNAL_HEAD); expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.FROZEN_PLAN); expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.FROZEN_ARTIFACT); expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.APPROVAL); expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.STEP_PTR); }); it('подпись M5_GREEN НЕ принимается за другой домен (cross-domain)', () => { const rec = { criterion_id: 'c1', occurrence: 1 }; const sig = signPayload(rec, KEY, RECEIPT_DOMAINS.M5_GREEN); expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.M5_GREEN)).toBe(true); expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.JOURNAL_HEAD)).toBe(false); expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.FROZEN_PLAN)).toBe(false); }); }); describe('VERIFY_PASS domain (G1)', () => { const KEY = 'test-secret-key-123'; it('VERIFY_PASS определён как verify-pass и отделён от M5_GREEN (R-31)', () => { expect(RECEIPT_DOMAINS.VERIFY_PASS).toBe('verify-pass'); const sig = signPayload({ a: 1 }, KEY, RECEIPT_DOMAINS.VERIFY_PASS); expect(verifyReceipt({ a: 1, sig }, KEY, RECEIPT_DOMAINS.VERIFY_PASS)).toBe(true); expect(verifyReceipt({ a: 1, sig }, KEY, RECEIPT_DOMAINS.M5_GREEN)).toBe(false); }); }); describe('canonicalJson', () => { it('sorts object keys deterministically', () => { expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}'); }); it('is stable regardless of insertion order', () => { expect(canonicalJson({ a: 1, b: 2 })).toBe(canonicalJson({ b: 2, a: 1 })); }); it('recurses into nested objects and arrays', () => { expect(canonicalJson({ z: [3, { y: 1, x: 2 }], a: 0 })) .toBe('{"a":0,"z":[3,{"x":2,"y":1}]}'); }); it('no whitespace', () => { expect(canonicalJson({ a: 1 })).not.toMatch(/\s/); }); }); describe('signPayload / verifyReceipt', () => { const KEY = 'test-secret-key-123'; it('signPayload returns a 64-char hex HMAC', () => { const sig = signPayload({ a: 1 }, KEY); expect(sig).toMatch(/^[0-9a-f]{64}$/); }); it('signPayload returns null without a key', () => { expect(signPayload({ a: 1 }, null)).toBe(null); expect(signPayload({ a: 1 }, '')).toBe(null); }); it('verifyReceipt true for a correctly signed record', () => { const rec = { kind: 'x', n: 5 }; const sig = signPayload(rec, KEY); expect(verifyReceipt({ ...rec, sig }, KEY)).toBe(true); }); it('verifyReceipt false when payload tampered after signing', () => { const rec = { kind: 'x', n: 5 }; const sig = signPayload(rec, KEY); expect(verifyReceipt({ ...rec, n: 6, sig }, KEY)).toBe(false); }); it('verifyReceipt false for a wrong key', () => { const rec = { kind: 'x', n: 5 }; const sig = signPayload(rec, KEY); expect(verifyReceipt({ ...rec, sig }, 'other-key')).toBe(false); }); it('verifyReceipt false for an unsigned record (no key → no trust)', () => { expect(verifyReceipt({ kind: 'x', n: 5 }, KEY)).toBe(false); }); it('verifyReceipt false when verifying key is null', () => { const sig = signPayload({ a: 1 }, KEY); expect(verifyReceipt({ a: 1, sig }, null)).toBe(false); }); });