Files
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

325 lines
8.9 KiB
TypeScript

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"',
);
});
});
});