import { describe, it, expect, vi } from "vitest"; import { generatePreviewToken, verifyPreviewToken, parseContentId, } from "../../../src/preview/tokens.js"; // Regex patterns for token validation const BASE64URL_INVALID_CHARS_REGEX = /[+/=]/; const BASE64_PLUS_PATTERN = /\+/g; const BASE64_SLASH_PATTERN = /\//g; const BASE64_PADDING_PATTERN = /=+$/; describe("preview tokens", () => { const testSecret = "test-secret-key-for-preview-tokens"; describe("generatePreviewToken", () => { it("generates a valid token", async () => { const token = await generatePreviewToken({ contentId: "posts:abc123", expiresIn: "1h", secret: testSecret, }); // Token should be non-empty string expect(token).toBeTruthy(); expect(typeof token).toBe("string"); // Token should have two parts (payload.signature) const parts = token.split("."); expect(parts.length).toBe(2); // Should be URL-safe (no +, /, or =) expect(token).not.toMatch(BASE64URL_INVALID_CHARS_REGEX); }); it("defaults to 1 hour expiry", async () => { const token = await generatePreviewToken({ contentId: "posts:abc123", secret: testSecret, }); const result = await verifyPreviewToken({ token, secret: testSecret }); expect(result.valid).toBe(true); if (result.valid) { // Should expire in roughly 1 hour const now = Math.floor(Date.now() / 1000); const expectedExpiry = now + 3600; expect(result.payload.exp).toBeGreaterThan(now); expect(result.payload.exp).toBeLessThanOrEqual(expectedExpiry + 1); } }); it("supports various duration formats", async () => { const durations = ["30s", "5m", "2h", "1d", "1w"]; for (const duration of durations) { const token = await generatePreviewToken({ contentId: "posts:test", expiresIn: duration, secret: testSecret, }); const result = await verifyPreviewToken({ token, secret: testSecret }); expect(result.valid).toBe(true); } }); it("supports numeric duration (seconds)", async () => { const token = await generatePreviewToken({ contentId: "posts:test", expiresIn: 7200, // 2 hours secret: testSecret, }); const result = await verifyPreviewToken({ token, secret: testSecret }); expect(result.valid).toBe(true); if (result.valid) { const now = Math.floor(Date.now() / 1000); expect(result.payload.exp).toBeGreaterThan(now + 7000); } }); it("throws on missing secret", async () => { await expect( generatePreviewToken({ contentId: "posts:abc123", secret: "", }), ).rejects.toThrow("Preview secret is required"); }); it("throws on invalid content ID format", async () => { await expect( generatePreviewToken({ contentId: "invalid-no-colon", secret: testSecret, }), ).rejects.toThrow('Content ID must be in format "collection:id"'); }); it("throws on invalid duration format", async () => { await expect( generatePreviewToken({ contentId: "posts:abc123", expiresIn: "invalid", secret: testSecret, }), ).rejects.toThrow("Invalid duration format"); }); }); describe("verifyPreviewToken", () => { it("accepts valid token", async () => { const token = await generatePreviewToken({ contentId: "posts:abc123", secret: testSecret, }); const result = await verifyPreviewToken({ token, secret: testSecret }); expect(result.valid).toBe(true); if (result.valid) { expect(result.payload.cid).toBe("posts:abc123"); expect(result.payload.exp).toBeGreaterThan(Date.now() / 1000); expect(result.payload.iat).toBeLessThanOrEqual(Date.now() / 1000); } }); it("rejects expired token", async () => { vi.useFakeTimers(); // Generate a token that expires in 60 seconds const token = await generatePreviewToken({ contentId: "posts:abc123", expiresIn: 60, secret: testSecret, }); // Fast-forward past expiry vi.advanceTimersByTime(61 * 1000); const result = await verifyPreviewToken({ token, secret: testSecret }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("expired"); } vi.useRealTimers(); }); it("rejects tampered token (modified payload)", async () => { const token = await generatePreviewToken({ contentId: "posts:abc123", secret: testSecret, }); // Tamper with the payload const [_payload, signature] = token.split("."); const tamperedPayload = btoa(JSON.stringify({ cid: "posts:hacked", exp: 9999999999, iat: 0 })) .replace(BASE64_PLUS_PATTERN, "-") .replace(BASE64_SLASH_PATTERN, "_") .replace(BASE64_PADDING_PATTERN, ""); const tamperedToken = `${tamperedPayload}.${signature}`; const result = await verifyPreviewToken({ token: tamperedToken, secret: testSecret, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("invalid"); } }); it("rejects token with wrong secret", async () => { const token = await generatePreviewToken({ contentId: "posts:abc123", secret: testSecret, }); const result = await verifyPreviewToken({ token, secret: "different-secret", }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("invalid"); } }); it("rejects malformed token (no separator)", async () => { const result = await verifyPreviewToken({ token: "nodotshere", secret: testSecret, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("malformed"); } }); it("rejects malformed token (too many parts)", async () => { const result = await verifyPreviewToken({ token: "a.b.c", secret: testSecret, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("malformed"); } }); it("rejects malformed token (invalid base64)", async () => { const result = await verifyPreviewToken({ token: "!!!.!!!", secret: testSecret, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("malformed"); } }); it("rejects token with missing fields", async () => { // Create a token with incomplete payload const _incompletePayload = btoa(JSON.stringify({ cid: "posts:abc" })) .replace(BASE64_PLUS_PATTERN, "-") .replace(BASE64_SLASH_PATTERN, "_") .replace(BASE64_PADDING_PATTERN, ""); // Need to sign it properly for the signature check to pass // but payload validation should fail // Actually, this will fail at signature validation since we can't sign without the secret // Let's test a different case - token where JSON is valid but fields are wrong type const badPayload = btoa(JSON.stringify({ cid: 123, exp: "not-a-number", iat: null })) .replace(BASE64_PLUS_PATTERN, "-") .replace(BASE64_SLASH_PATTERN, "_") .replace(BASE64_PADDING_PATTERN, ""); const result = await verifyPreviewToken({ token: `${badPayload}.fakesignature`, secret: testSecret, }); expect(result.valid).toBe(false); }); it("throws on missing secret", async () => { await expect(verifyPreviewToken({ token: "some.token", secret: "" })).rejects.toThrow( "Preview secret is required", ); }); it("returns 'none' error for null token", async () => { const result = await verifyPreviewToken({ token: null, secret: testSecret, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("none"); } }); it("returns 'none' error for undefined token", async () => { const result = await verifyPreviewToken({ token: undefined, secret: testSecret, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("none"); } }); it("extracts token from URL", async () => { const token = await generatePreviewToken({ contentId: "posts:abc123", secret: testSecret, }); const url = new URL(`https://example.com/posts/abc123?_preview=${token}`); const result = await verifyPreviewToken({ url, secret: testSecret }); expect(result.valid).toBe(true); if (result.valid) { expect(result.payload.cid).toBe("posts:abc123"); } }); it("returns 'none' for URL without _preview param", async () => { const url = new URL("https://example.com/posts/abc123"); const result = await verifyPreviewToken({ url, secret: testSecret }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.error).toBe("none"); } }); }); describe("parseContentId", () => { it("parses valid content ID", () => { const result = parseContentId("posts:abc123"); expect(result.collection).toBe("posts"); expect(result.id).toBe("abc123"); }); it("handles ID with colons", () => { const result = parseContentId("posts:id:with:colons"); expect(result.collection).toBe("posts"); expect(result.id).toBe("id:with:colons"); }); it("throws on invalid format", () => { expect(() => parseContentId("invalid")).toThrow( 'Content ID must be in format "collection:id"', ); }); }); });