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
This commit is contained in:
324
packages/core/tests/unit/preview/tokens.test.ts
Normal file
324
packages/core/tests/unit/preview/tokens.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
161
packages/core/tests/unit/preview/urls.test.ts
Normal file
161
packages/core/tests/unit/preview/urls.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getPreviewUrl, buildPreviewUrl } from "../../../src/preview/urls.js";
|
||||
|
||||
// Regex patterns for URL validation
|
||||
const RELATIVE_PREVIEW_URL_REGEX = /^\/posts\/hello-world\?_preview=/;
|
||||
const ABSOLUTE_PREVIEW_URL_REGEX = /^https:\/\/example\.com\/posts\/hello-world\?_preview=/;
|
||||
const BLOG_PREVIEW_URL_REGEX = /^\/blog\/hello-world\?_preview=/;
|
||||
const CONTENT_PREVIEW_URL_REGEX = /^\/content\/posts\/view\/hello-world\?_preview=/;
|
||||
const BASE64URL_INVALID_CHARS_REGEX = /[+/=]/;
|
||||
|
||||
describe("preview URLs", () => {
|
||||
const testSecret = "test-secret-key-for-preview-tokens";
|
||||
|
||||
describe("getPreviewUrl", () => {
|
||||
it("generates relative URL by default", async () => {
|
||||
const url = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello-world",
|
||||
secret: testSecret,
|
||||
});
|
||||
|
||||
// Should start with path
|
||||
expect(url).toMatch(RELATIVE_PREVIEW_URL_REGEX);
|
||||
|
||||
// Should have a token
|
||||
const urlObj = new URL(url, "http://example.com");
|
||||
const token = urlObj.searchParams.get("_preview");
|
||||
expect(token).toBeTruthy();
|
||||
expect(token!.split(".").length).toBe(2);
|
||||
});
|
||||
|
||||
it("generates absolute URL with baseUrl", async () => {
|
||||
const url = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello-world",
|
||||
secret: testSecret,
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
|
||||
expect(url).toMatch(ABSOLUTE_PREVIEW_URL_REGEX);
|
||||
});
|
||||
|
||||
it("respects custom path pattern", async () => {
|
||||
const url = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello-world",
|
||||
secret: testSecret,
|
||||
pathPattern: "/blog/{id}",
|
||||
});
|
||||
|
||||
expect(url).toMatch(BLOG_PREVIEW_URL_REGEX);
|
||||
});
|
||||
|
||||
it("supports complex path patterns", async () => {
|
||||
const url = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello-world",
|
||||
secret: testSecret,
|
||||
pathPattern: "/content/{collection}/view/{id}",
|
||||
});
|
||||
|
||||
expect(url).toMatch(CONTENT_PREVIEW_URL_REGEX);
|
||||
});
|
||||
|
||||
it("generates URL-safe tokens", async () => {
|
||||
const url = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "test-id",
|
||||
secret: testSecret,
|
||||
});
|
||||
|
||||
// Token should not contain URL-unsafe characters
|
||||
const urlObj = new URL(url, "http://example.com");
|
||||
const token = urlObj.searchParams.get("_preview");
|
||||
expect(token).not.toMatch(BASE64URL_INVALID_CHARS_REGEX);
|
||||
});
|
||||
|
||||
it("substitutes {locale} placeholder", async () => {
|
||||
const url = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello-world",
|
||||
secret: testSecret,
|
||||
pathPattern: "/{locale}/{id}",
|
||||
locale: "pt-br",
|
||||
});
|
||||
|
||||
expect(url).toMatch(/^\/pt-br\/hello-world\?_preview=/);
|
||||
});
|
||||
|
||||
it("collapses empty {locale} segment", async () => {
|
||||
const url = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello-world",
|
||||
secret: testSecret,
|
||||
pathPattern: "/{locale}/{id}",
|
||||
locale: "",
|
||||
});
|
||||
|
||||
// Empty locale should not leave a leading double slash.
|
||||
expect(url).toMatch(/^\/hello-world\?_preview=/);
|
||||
});
|
||||
|
||||
it("respects expiresIn option", async () => {
|
||||
const shortUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "test",
|
||||
secret: testSecret,
|
||||
expiresIn: "30m",
|
||||
});
|
||||
|
||||
const longUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "test",
|
||||
secret: testSecret,
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
// Both should be valid but different tokens
|
||||
expect(shortUrl).not.toBe(longUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPreviewUrl", () => {
|
||||
it("builds URL from existing token", () => {
|
||||
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-signature";
|
||||
|
||||
const url = buildPreviewUrl({
|
||||
path: "/posts/hello-world",
|
||||
token,
|
||||
});
|
||||
|
||||
expect(url).toBe(`/posts/hello-world?_preview=${token}`);
|
||||
});
|
||||
|
||||
it("builds absolute URL with baseUrl", () => {
|
||||
const token = "test-token";
|
||||
|
||||
const url = buildPreviewUrl({
|
||||
path: "/posts/hello-world",
|
||||
token,
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
|
||||
expect(url).toBe(`https://example.com/posts/hello-world?_preview=${token}`);
|
||||
});
|
||||
|
||||
it("preserves existing query params in path", () => {
|
||||
const token = "test-token";
|
||||
|
||||
// Note: buildPreviewUrl doesn't preserve existing params, it starts fresh
|
||||
// This is intentional - the path should be clean
|
||||
const url = buildPreviewUrl({
|
||||
path: "/posts/hello-world",
|
||||
token,
|
||||
});
|
||||
|
||||
expect(url).toContain("_preview=test-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user