133 lines
5.3 KiB
TypeScript
133 lines
5.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
DeletionTokenSecretMissingError,
|
|
signDeletionToken,
|
|
verifyDeletionToken,
|
|
} from "./deletionToken.server";
|
|
|
|
const TEST_SECRET = "x".repeat(48);
|
|
|
|
describe("deletionToken", () => {
|
|
const originalDeletion = process.env.DELETION_TOKEN_SECRET;
|
|
const originalSession = process.env.SESSION_SECRET;
|
|
|
|
beforeEach(() => {
|
|
process.env.DELETION_TOKEN_SECRET = TEST_SECRET;
|
|
delete process.env.SESSION_SECRET;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalDeletion === undefined) delete process.env.DELETION_TOKEN_SECRET;
|
|
else process.env.DELETION_TOKEN_SECRET = originalDeletion;
|
|
if (originalSession === undefined) delete process.env.SESSION_SECRET;
|
|
else process.env.SESSION_SECRET = originalSession;
|
|
});
|
|
|
|
it("sign + verify roundtrip returns userId and requestedAt", () => {
|
|
const requestedAt = new Date("2026-05-17T12:00:00Z");
|
|
const tok = signDeletionToken({ userId: "user_1", requestedAt });
|
|
const decoded = verifyDeletionToken(tok);
|
|
expect(decoded).not.toBeNull();
|
|
expect(decoded?.userId).toBe("user_1");
|
|
expect(decoded?.requestedAt).toBe(Math.floor(requestedAt.getTime() / 1000));
|
|
});
|
|
|
|
it("defaults to 24-hour TTL", () => {
|
|
const fixedNow = () => new Date("2026-05-17T00:00:00Z");
|
|
const tok = signDeletionToken({ userId: "user_1", now: fixedNow });
|
|
const decoded = verifyDeletionToken(tok, { now: fixedNow });
|
|
expect(decoded).not.toBeNull();
|
|
expect((decoded!.exp - decoded!.iat) / (60 * 60)).toBeCloseTo(24);
|
|
});
|
|
|
|
it("clamps TTL to max 72 hours", () => {
|
|
const fixedNow = () => new Date("2026-05-17T00:00:00Z");
|
|
const tok = signDeletionToken({ userId: "user_1", expiresInHours: 9999, now: fixedNow });
|
|
const decoded = verifyDeletionToken(tok, { now: fixedNow });
|
|
expect(decoded).not.toBeNull();
|
|
expect((decoded!.exp - decoded!.iat) / (60 * 60)).toBeLessThanOrEqual(72);
|
|
});
|
|
|
|
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 = signDeletionToken({ userId: "user_1", expiresInHours: 1, now: past });
|
|
expect(verifyDeletionToken(tok, { now: future })).toBeNull();
|
|
});
|
|
|
|
it("returns null for tampered payload", () => {
|
|
const tok = signDeletionToken({ userId: "user_1" });
|
|
const [v, _p, s] = tok.split(".");
|
|
const tampered = Buffer.from(
|
|
JSON.stringify({ userId: "user_hijack", requestedAt: 0, iat: 0, exp: 2 ** 31 - 1 }),
|
|
"utf8",
|
|
).toString("base64url");
|
|
expect(verifyDeletionToken(`${v}.${tampered}.${s}`)).toBeNull();
|
|
});
|
|
|
|
it("returns null for tampered signature", () => {
|
|
const tok = signDeletionToken({ userId: "user_1" });
|
|
const [v, p] = tok.split(".");
|
|
const badSig = Buffer.alloc(32, 0).toString("base64url");
|
|
expect(verifyDeletionToken(`${v}.${p}.${badSig}`)).toBeNull();
|
|
});
|
|
|
|
it("returns null for wrong version prefix", () => {
|
|
const tok = signDeletionToken({ userId: "user_1" });
|
|
const [, p, s] = tok.split(".");
|
|
expect(verifyDeletionToken(`v1.${p}.${s}`)).toBeNull();
|
|
});
|
|
|
|
it("returns null for malformed tokens", () => {
|
|
expect(verifyDeletionToken("")).toBeNull();
|
|
expect(verifyDeletionToken(null)).toBeNull();
|
|
expect(verifyDeletionToken(undefined)).toBeNull();
|
|
expect(verifyDeletionToken("not.a.token.extrapart")).toBeNull();
|
|
expect(verifyDeletionToken("dsgvo1.@@@.bad")).toBeNull();
|
|
});
|
|
|
|
it("throws when neither DELETION_TOKEN_SECRET nor SESSION_SECRET is set", () => {
|
|
delete process.env.DELETION_TOKEN_SECRET;
|
|
delete process.env.SESSION_SECRET;
|
|
expect(() => signDeletionToken({ userId: "user_1" })).toThrow(DeletionTokenSecretMissingError);
|
|
});
|
|
|
|
it("throws when configured secret is shorter than 32 chars", () => {
|
|
process.env.DELETION_TOKEN_SECRET = "too-short";
|
|
expect(() => signDeletionToken({ userId: "user_1" })).toThrow(DeletionTokenSecretMissingError);
|
|
});
|
|
|
|
it("falls back to SESSION_SECRET when DELETION_TOKEN_SECRET is unset", () => {
|
|
delete process.env.DELETION_TOKEN_SECRET;
|
|
process.env.SESSION_SECRET = "s".repeat(48);
|
|
const tok = signDeletionToken({ userId: "user_1" });
|
|
expect(verifyDeletionToken(tok)?.userId).toBe("user_1");
|
|
});
|
|
|
|
it("verify returns null when secret env is missing", () => {
|
|
const tok = signDeletionToken({ userId: "user_1", secret: TEST_SECRET });
|
|
delete process.env.DELETION_TOKEN_SECRET;
|
|
delete process.env.SESSION_SECRET;
|
|
expect(verifyDeletionToken(tok)).toBeNull();
|
|
});
|
|
|
|
it("rejects empty userId on sign", () => {
|
|
expect(() => signDeletionToken({ userId: "" })).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 = signDeletionToken({ userId: "user_1", secret: secretA });
|
|
expect(verifyDeletionToken(tok, { secret: secretB })).toBeNull();
|
|
expect(verifyDeletionToken(tok, { secret: secretA })?.userId).toBe("user_1");
|
|
});
|
|
|
|
it("rejects lead-token format (cross-system isolation)", () => {
|
|
// A v1 lead-token shape must NOT verify under dsgvo1.
|
|
const leadShape = "v1." + Buffer.from(JSON.stringify({ userId: "u", iat: 0, exp: 2 ** 31 - 1 }), "utf8").toString("base64url") + ".AAAA";
|
|
expect(verifyDeletionToken(leadShape)).toBeNull();
|
|
});
|
|
});
|