import { createHmac } from 'node:crypto'; import { deriveTokenKey } from '@dorfteich/shared/token-crypto'; import { describe, expect, it } from 'vitest'; import { LEGACY_VERIFY_UNTIL, signUnsubscribeToken, verifyUnsubscribeToken, } from './unsubscribe-token'; const secret = 'test-secret-at-least-16-chars-long'; const TTL_MS = 90 * 24 * 60 * 60 * 1000; /** A token as the pre-#188 implementation minted it: root secret + purpose prefix. */ function legacyToken(userId: string, now = Date.now()): string { const body = Buffer.from( JSON.stringify({ userId, exp: Math.floor(now / 1000) + 90 * 24 * 60 * 60 }), 'utf8', ).toString('base64url'); const sig = createHmac('sha256', secret).update(`digest-unsubscribe.${body}`).digest('base64url'); return `${body}.${sig}`; } describe('unsubscribe token', () => { it('round-trips a user id', () => { expect(verifyUnsubscribeToken(signUnsubscribeToken('u1', secret), secret)).toBe('u1'); }); it('rejects an expired token', () => { const token = signUnsubscribeToken('u1', secret); expect(verifyUnsubscribeToken(token, secret, Date.now() + TTL_MS + 1000)).toBeNull(); }); it('rejects garbage and a wrong secret', () => { expect(verifyUnsubscribeToken('nonsense', secret)).toBeNull(); expect(verifyUnsubscribeToken('a.b', secret)).toBeNull(); const token = signUnsubscribeToken('u1', secret); expect(verifyUnsubscribeToken(token, 'another-secret-16-chars')).toBeNull(); }); it("rejects a token HMACed with a different purpose's subkey (ADR 0020)", () => { const body = Buffer.from( JSON.stringify({ userId: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }), 'utf8', ).toString('base64url'); const sig = createHmac('sha256', deriveTokenKey(secret, 'collab')) .update(body) .digest('base64url'); expect(verifyUnsubscribeToken(`${body}.${sig}`, secret)).toBeNull(); }); it('accepts a pre-separation legacy token inside the dual-verify window', () => { const inWindow = LEGACY_VERIFY_UNTIL - 24 * 60 * 60 * 1000; expect(verifyUnsubscribeToken(legacyToken('u1', inWindow - TTL_MS / 2), secret, inWindow)).toBe( 'u1', ); }); it('rejects a legacy token once the dual-verify window has closed', () => { const afterWindow = LEGACY_VERIFY_UNTIL + 1000; // Unexpired on its own terms — rejected purely because the window closed. const token = legacyToken('u1', afterWindow - 1000); expect(verifyUnsubscribeToken(token, secret, afterWindow)).toBeNull(); }); it('verifies freshly minted tokens via the subkey, independent of the window', () => { const afterWindow = LEGACY_VERIFY_UNTIL + 24 * 60 * 60 * 1000; const token = signUnsubscribeToken('u1', secret, afterWindow); expect(verifyUnsubscribeToken(token, secret, afterWindow + 1000)).toBe('u1'); }); });