Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/dsgvo/deletionToken.server.test.ts
T

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();
});
});