106 lines
4.2 KiB
TypeScript
106 lines
4.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { LeadTokenSecretMissingError, signLeadToken, verifyLeadToken } from "./leadToken.server";
|
|
|
|
const TEST_SECRET = "x".repeat(48);
|
|
|
|
describe("leadToken", () => {
|
|
const originalEnv = process.env.LEAD_CONVERSION_SECRET;
|
|
|
|
beforeEach(() => {
|
|
process.env.LEAD_CONVERSION_SECRET = TEST_SECRET;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalEnv === undefined) delete process.env.LEAD_CONVERSION_SECRET;
|
|
else process.env.LEAD_CONVERSION_SECRET = originalEnv;
|
|
});
|
|
|
|
it("sign + verify roundtrip returns same leadId and planId", () => {
|
|
const tok = signLeadToken({ leadId: "lead_1", planId: "plan_pro" });
|
|
const decoded = verifyLeadToken(tok);
|
|
expect(decoded).toMatchObject({ leadId: "lead_1", planId: "plan_pro" });
|
|
expect(decoded?.exp).toBeGreaterThan(decoded?.iat ?? 0);
|
|
});
|
|
|
|
it("defaults to 7-day TTL", () => {
|
|
const fixedNow = () => new Date("2026-05-17T00:00:00Z");
|
|
const tok = signLeadToken({ leadId: "lead_1", now: fixedNow });
|
|
const decoded = verifyLeadToken(tok, { now: fixedNow });
|
|
expect(decoded).not.toBeNull();
|
|
expect((decoded!.exp - decoded!.iat) / (24 * 60 * 60)).toBeCloseTo(7);
|
|
});
|
|
|
|
it("clamps TTL to max 30 days", () => {
|
|
const fixedNow = () => new Date("2026-05-17T00:00:00Z");
|
|
const tok = signLeadToken({ leadId: "lead_1", expiresInDays: 9999, now: fixedNow });
|
|
const decoded = verifyLeadToken(tok, { now: fixedNow });
|
|
expect(decoded).not.toBeNull();
|
|
expect((decoded!.exp - decoded!.iat) / (24 * 60 * 60)).toBeLessThanOrEqual(30);
|
|
});
|
|
|
|
it("returns null for expired tokens", () => {
|
|
const past = () => new Date("2026-01-01T00:00:00Z");
|
|
const future = () => new Date("2026-12-31T00:00:00Z");
|
|
const tok = signLeadToken({ leadId: "lead_1", expiresInDays: 1, now: past });
|
|
expect(verifyLeadToken(tok, { now: future })).toBeNull();
|
|
});
|
|
|
|
it("returns null for tampered payload", () => {
|
|
const tok = signLeadToken({ leadId: "lead_1" });
|
|
const [v, p, s] = tok.split(".");
|
|
// Re-encode with a different leadId — signature should no longer match.
|
|
const tampered = Buffer.from(JSON.stringify({ leadId: "lead_hijack", iat: 0, exp: 2 ** 31 - 1 }), "utf8").toString("base64url");
|
|
expect(verifyLeadToken(`${v}.${tampered}.${s}`)).toBeNull();
|
|
expect(p).toBeDefined();
|
|
});
|
|
|
|
it("returns null for tampered signature", () => {
|
|
const tok = signLeadToken({ leadId: "lead_1" });
|
|
const [v, p] = tok.split(".");
|
|
const badSig = Buffer.alloc(32, 0).toString("base64url");
|
|
expect(verifyLeadToken(`${v}.${p}.${badSig}`)).toBeNull();
|
|
});
|
|
|
|
it("returns null when version prefix is wrong", () => {
|
|
const tok = signLeadToken({ leadId: "lead_1" });
|
|
const [, p, s] = tok.split(".");
|
|
expect(verifyLeadToken(`v2.${p}.${s}`)).toBeNull();
|
|
});
|
|
|
|
it("returns null for malformed tokens", () => {
|
|
expect(verifyLeadToken("")).toBeNull();
|
|
expect(verifyLeadToken(null)).toBeNull();
|
|
expect(verifyLeadToken(undefined)).toBeNull();
|
|
expect(verifyLeadToken("not.a.token.extrapart")).toBeNull();
|
|
expect(verifyLeadToken("v1.@@@.bad")).toBeNull();
|
|
});
|
|
|
|
it("throws when LEAD_CONVERSION_SECRET is unset", () => {
|
|
delete process.env.LEAD_CONVERSION_SECRET;
|
|
expect(() => signLeadToken({ leadId: "lead_1" })).toThrow(LeadTokenSecretMissingError);
|
|
});
|
|
|
|
it("throws when LEAD_CONVERSION_SECRET is too short", () => {
|
|
process.env.LEAD_CONVERSION_SECRET = "too-short";
|
|
expect(() => signLeadToken({ leadId: "lead_1" })).toThrow(LeadTokenSecretMissingError);
|
|
});
|
|
|
|
it("verify returns null when secret env is missing", () => {
|
|
const tok = signLeadToken({ leadId: "lead_1", secret: TEST_SECRET });
|
|
delete process.env.LEAD_CONVERSION_SECRET;
|
|
expect(verifyLeadToken(tok)).toBeNull();
|
|
});
|
|
|
|
it("rejects empty leadId on sign", () => {
|
|
expect(() => signLeadToken({ leadId: "" })).toThrow(TypeError);
|
|
});
|
|
|
|
it("rotation: token signed with secret-A fails verify under secret-B", () => {
|
|
const secretA = "a".repeat(48);
|
|
const secretB = "b".repeat(48);
|
|
const tok = signLeadToken({ leadId: "lead_1", secret: secretA });
|
|
expect(verifyLeadToken(tok, { secret: secretB })).toBeNull();
|
|
expect(verifyLeadToken(tok, { secret: secretA })?.leadId).toBe("lead_1");
|
|
});
|
|
});
|