import { signAction, verifyAction, actionUrl, ACTION_TTL_MS } from '../../src/intake/actionLinks'; const SECRET = 'test-intake-secret'; const NOW = 1_800_000_000_000; const EXPIRY = NOW + ACTION_TTL_MS; beforeEach(() => { process.env.INTAKE_ACTION_SECRET = SECRET; process.env.PUBLIC_URL = 'https://shop.example.com'; }); describe('signAction / verifyAction', () => { it('accepts a signature it produced', () => { const sig = signAction(7, 'discard', EXPIRY); expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(true); }); // Each of these is a different link. A signature that survives any of these // swaps is a signature that authorises more than it names. it('refuses a signature reused for another item', () => { const sig = signAction(7, 'discard', EXPIRY); expect(verifyAction(8, 'discard', EXPIRY, sig, NOW)).toBe(false); }); it('refuses a signature reused for another action', () => { const sig = signAction(7, 'discard', EXPIRY); expect(verifyAction(7, 'regenerate', EXPIRY, sig, NOW)).toBe(false); }); // Otherwise the expiry is decoration: anyone holding an expired link could // extend it themselves by editing the timestamp. it('refuses a signature whose expiry was altered', () => { const sig = signAction(7, 'discard', EXPIRY); expect(verifyAction(7, 'discard', EXPIRY + 1000, sig, NOW)).toBe(false); }); it('refuses an expired link even with a valid signature', () => { const sig = signAction(7, 'discard', EXPIRY); expect(verifyAction(7, 'discard', EXPIRY, sig, EXPIRY + 1)).toBe(false); }); it('refuses a malformed signature without throwing', () => { expect(verifyAction(7, 'discard', EXPIRY, 'not-a-signature', NOW)).toBe(false); expect(verifyAction(7, 'discard', EXPIRY, '', NOW)).toBe(false); }); // This is what makes rotating the secret revoke every outstanding link. it('refuses a signature made with a different secret', () => { const sig = signAction(7, 'discard', EXPIRY); process.env.INTAKE_ACTION_SECRET = 'a-different-secret'; expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(false); }); it('refuses everything when there is no secret at all', () => { const sig = signAction(7, 'discard', EXPIRY); delete process.env.INTAKE_ACTION_SECRET; expect(verifyAction(7, 'discard', EXPIRY, sig, NOW)).toBe(false); }); }); describe('actionUrl', () => { it('builds an absolute url carrying the expiry and signature', () => { const url = actionUrl(7, 'discard'); expect(url).toContain('https://shop.example.com/api/intake-actions/7/discard'); expect(url).toMatch(/expires=\d+/); expect(url).toMatch(/sig=[A-Za-z0-9_-]+/); }); // Absent secret is a working configuration: the notification still sends with // its review link. A link that cannot be verified must never be offered. it('returns null when there is no secret', () => { delete process.env.INTAKE_ACTION_SECRET; expect(actionUrl(7, 'discard')).toBeNull(); }); it('returns null when there is no public url to build against', () => { delete process.env.PUBLIC_URL; expect(actionUrl(7, 'discard')).toBeNull(); }); it('does not double the slash when PUBLIC_URL has a trailing one', () => { process.env.PUBLIC_URL = 'https://shop.example.com/'; expect(actionUrl(7, 'discard')).toContain('https://shop.example.com/api/intake-actions/'); }); });