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:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
/**
* Integration test for the full preview snapshot auth flow.
*
* Tests the complete chain that would have caught bug #3:
* signPreviewUrl → middleware builds header → snapshot endpoint parses and verifies
*
* The signing side (signPreviewUrl) lives in @emdash-cms/cloudflare, but we
* inline the same HMAC logic here to test the format contract without
* cross-package imports.
*/
import { sql } from "kysely";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
generateSnapshot,
parsePreviewSignatureHeader,
verifyPreviewSignature,
} from "../../../src/api/handlers/snapshot.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabaseWithCollections } from "../../utils/test-db.js";
const SECRET = "test-preview-secret";
/**
* Sign a preview URL using the same HMAC-SHA256 logic as
* @emdash-cms/cloudflare signPreviewUrl(). Inlined here so we test
* the format contract without cross-package deps.
*/
async function signPreview(
source: string,
ttl = 3600,
): Promise<{ source: string; exp: number; sig: string }> {
const exp = Math.floor(Date.now() / 1000) + ttl;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const buffer = await crypto.subtle.sign("HMAC", key, encoder.encode(`${source}:${exp}`));
const sig = Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
return { source, exp, sig };
}
/**
* Build the X-Preview-Signature header value the same way the
* preview middleware does: "source:exp:sig"
*/
function buildSignatureHeader(parts: { source: string; exp: number; sig: string }): string {
return `${parts.source}:${parts.exp}:${parts.sig}`;
}
describe("preview snapshot auth flow", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
await db.destroy();
});
it("end-to-end: signed preview URL → header → snapshot access", async () => {
// 1. Insert some content so snapshot has data
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
VALUES ('p1', 'test-post', 'published', 'Test', 'Body', datetime('now'), datetime('now'), 1)
`.execute(db);
// 2. Sign a preview URL (same logic as @emdash-cms/cloudflare signPreviewUrl)
const signed = await signPreview("https://mysite.com");
// 3. Build the header the way the preview middleware does
const headerValue = buildSignatureHeader(signed);
// 4. Parse the header the way the snapshot endpoint does
const parsed = parsePreviewSignatureHeader(headerValue);
expect(parsed).not.toBeNull();
expect(parsed!.source).toBe("https://mysite.com");
expect(parsed!.exp).toBe(signed.exp);
expect(parsed!.sig).toBe(signed.sig);
// 5. Verify the signature the way the snapshot endpoint does
const valid = await verifyPreviewSignature(parsed!.source, parsed!.exp, parsed!.sig, SECRET);
expect(valid).toBe(true);
// 6. Actually generate the snapshot (proves auth would grant access)
const snapshot = await generateSnapshot(db);
expect(snapshot.tables.ec_post).toHaveLength(1);
expect(snapshot.tables.ec_post[0]!.slug).toBe("test-post");
});
it("rejects tampered signature", async () => {
const signed = await signPreview("https://mysite.com");
const headerValue = buildSignatureHeader(signed);
const parsed = parsePreviewSignatureHeader(headerValue);
expect(parsed).not.toBeNull();
// Tamper with the signature
const valid = await verifyPreviewSignature(parsed!.source, parsed!.exp, "a".repeat(64), SECRET);
expect(valid).toBe(false);
});
it("rejects wrong secret", async () => {
const signed = await signPreview("https://mysite.com");
const headerValue = buildSignatureHeader(signed);
const parsed = parsePreviewSignatureHeader(headerValue);
expect(parsed).not.toBeNull();
const valid = await verifyPreviewSignature(
parsed!.source,
parsed!.exp,
parsed!.sig,
"wrong-secret",
);
expect(valid).toBe(false);
});
it("rejects expired signature", async () => {
// Sign with TTL of -1 (already expired)
const signed = await signPreview("https://mysite.com", -1);
const headerValue = buildSignatureHeader(signed);
const parsed = parsePreviewSignatureHeader(headerValue);
expect(parsed).not.toBeNull();
const valid = await verifyPreviewSignature(parsed!.source, parsed!.exp, parsed!.sig, SECRET);
expect(valid).toBe(false);
});
});
describe("parsePreviewSignatureHeader", () => {
it("parses source URLs with colons correctly", async () => {
const signed = await signPreview("https://mysite.com:8080");
const header = buildSignatureHeader(signed);
const parsed = parsePreviewSignatureHeader(header);
expect(parsed).not.toBeNull();
expect(parsed!.source).toBe("https://mysite.com:8080");
expect(parsed!.exp).toBe(signed.exp);
expect(parsed!.sig).toBe(signed.sig);
});
it("rejects empty string", () => {
expect(parsePreviewSignatureHeader("")).toBeNull();
});
it("rejects header with no colons", () => {
expect(parsePreviewSignatureHeader("noseparators")).toBeNull();
});
it("rejects header with sig wrong length", () => {
expect(parsePreviewSignatureHeader("https://x.com:12345:tooshort")).toBeNull();
});
it("rejects header with non-numeric exp", () => {
expect(parsePreviewSignatureHeader(`https://x.com:notanumber:${"a".repeat(64)}`)).toBeNull();
});
});

View File

@@ -0,0 +1,217 @@
import { sql } from "kysely";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Snapshot } from "../../../src/api/handlers/snapshot.js";
import { generateSnapshot } from "../../../src/api/handlers/snapshot.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabaseWithCollections } from "../../utils/test-db.js";
describe("generateSnapshot", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
await db.destroy();
});
it("returns empty tables when no content exists", async () => {
const snapshot = await generateSnapshot(db);
expect(snapshot.generatedAt).toBeTruthy();
expect(typeof snapshot.generatedAt).toBe("string");
// Schema should include ec_post and ec_page (even with no rows)
expect(snapshot.schema).toHaveProperty("ec_post");
expect(snapshot.schema).toHaveProperty("ec_page");
expect(snapshot.schema.ec_post.columns).toContain("id");
expect(snapshot.schema.ec_post.columns).toContain("title");
expect(snapshot.schema.ec_post.columns).toContain("slug");
expect(snapshot.schema.ec_post.columns).toContain("status");
// System tables with data should appear
expect(snapshot.schema).toHaveProperty("_emdash_collections");
expect(snapshot.schema).toHaveProperty("_emdash_fields");
// _emdash_collections should have 2 rows (post + page)
expect(snapshot.tables._emdash_collections).toHaveLength(2);
});
it("includes published content and excludes drafts by default", async () => {
// Insert a published post
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
VALUES ('pub1', 'hello-world', 'published', 'Hello World', 'Content here', datetime('now'), datetime('now'), 1)
`.execute(db);
// Insert a draft post
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
VALUES ('draft1', 'draft-post', 'draft', 'Draft Post', 'Draft content', datetime('now'), datetime('now'), 1)
`.execute(db);
const snapshot = await generateSnapshot(db);
// Only published content should appear
expect(snapshot.tables.ec_post).toHaveLength(1);
expect(snapshot.tables.ec_post[0].slug).toBe("hello-world");
});
it("includes drafts when includeDrafts is true", async () => {
// Insert a published post
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
VALUES ('pub1', 'hello-world', 'published', 'Hello World', 'Content', datetime('now'), datetime('now'), 1)
`.execute(db);
// Insert a draft post
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
VALUES ('draft1', 'draft-post', 'draft', 'Draft Post', 'Draft', datetime('now'), datetime('now'), 1)
`.execute(db);
const snapshot = await generateSnapshot(db, { includeDrafts: true });
// Both should appear
expect(snapshot.tables.ec_post).toHaveLength(2);
});
it("excludes soft-deleted content", async () => {
// Insert a published post
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
VALUES ('pub1', 'live-post', 'published', 'Live', 'Content', datetime('now'), datetime('now'), 1)
`.execute(db);
// Insert a soft-deleted post
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, deleted_at, version)
VALUES ('del1', 'deleted-post', 'published', 'Deleted', 'Gone', datetime('now'), datetime('now'), datetime('now'), 1)
`.execute(db);
const snapshot = await generateSnapshot(db);
expect(snapshot.tables.ec_post).toHaveLength(1);
expect(snapshot.tables.ec_post[0].slug).toBe("live-post");
});
it("excludes auth and security tables", async () => {
const snapshot = await generateSnapshot(db);
// These should not appear in schema or tables
expect(snapshot.schema).not.toHaveProperty("users");
expect(snapshot.schema).not.toHaveProperty("sessions");
expect(snapshot.schema).not.toHaveProperty("credentials");
expect(snapshot.schema).not.toHaveProperty("challenges");
expect(snapshot.schema).not.toHaveProperty("_emdash_api_tokens");
expect(snapshot.schema).not.toHaveProperty("_emdash_oauth_tokens");
});
it("includes system tables needed for rendering", async () => {
const snapshot = await generateSnapshot(db);
// These system tables should have schema entries
expect(snapshot.schema).toHaveProperty("_emdash_collections");
expect(snapshot.schema).toHaveProperty("_emdash_fields");
expect(snapshot.schema).toHaveProperty("_emdash_migrations");
expect(snapshot.schema).toHaveProperty("options");
});
it("includes column type info in schema", async () => {
const snapshot = await generateSnapshot(db);
const postSchema = snapshot.schema.ec_post;
expect(postSchema).toBeDefined();
expect(postSchema.types).toBeDefined();
// PRAGMA table_info returns types as declared (case-sensitive)
// Kysely creates tables with lowercase types
expect(postSchema.types!.id.toLowerCase()).toBe("text");
expect(postSchema.types!.version.toLowerCase()).toBe("integer");
});
it("snapshot shape matches DO expectation", async () => {
await sql`
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
VALUES ('p1', 'test', 'published', 'Test', 'Body', datetime('now'), datetime('now'), 1)
`.execute(db);
const snapshot: Snapshot = await generateSnapshot(db);
// Verify shape matches what EmDashPreviewDB.applySnapshot expects
expect(snapshot).toHaveProperty("tables");
expect(snapshot).toHaveProperty("schema");
expect(snapshot).toHaveProperty("generatedAt");
expect(typeof snapshot.generatedAt).toBe("string");
// Tables are Record<string, Record<string, unknown>[]>
for (const [tableName, rows] of Object.entries(snapshot.tables)) {
expect(typeof tableName).toBe("string");
expect(Array.isArray(rows)).toBe(true);
for (const row of rows) {
expect(typeof row).toBe("object");
}
}
// Schema has columns and types
for (const [tableName, info] of Object.entries(snapshot.schema)) {
expect(typeof tableName).toBe("string");
expect(Array.isArray(info.columns)).toBe(true);
if (info.types) {
expect(typeof info.types).toBe("object");
}
}
});
it("filters options table to safe rendering prefixes only", async () => {
// Insert site settings (safe — should be included)
await sql`INSERT INTO options (name, value) VALUES ('site:title', '"My Site"')`.execute(db);
await sql`INSERT INTO options (name, value) VALUES ('site:tagline', '"Welcome"')`.execute(db);
// Insert plugin secrets (unsafe — should be excluded)
await sql`INSERT INTO options (name, value) VALUES ('plugin:smtp:api_key', '"sk-secret-123"')`.execute(
db,
);
await sql`INSERT INTO options (name, value) VALUES ('plugin:seo:license', '"lic-456"')`.execute(
db,
);
// Insert setup/auth data (unsafe — should be excluded)
await sql`INSERT INTO options (name, value) VALUES ('emdash:setup_complete', 'true')`.execute(
db,
);
await sql`INSERT INTO options (name, value) VALUES ('emdash:passkey_pending:user1', '{"challenge":"abc"}')`.execute(
db,
);
const snapshot = await generateSnapshot(db);
const optionsRows = snapshot.tables.options;
expect(optionsRows).toBeDefined();
expect(optionsRows).toHaveLength(2);
const names = optionsRows.map((r) => r.name);
expect(names).toContain("site:title");
expect(names).toContain("site:tagline");
expect(names).not.toContain("plugin:smtp:api_key");
expect(names).not.toContain("plugin:seo:license");
expect(names).not.toContain("emdash:setup_complete");
expect(names).not.toContain("emdash:passkey_pending:user1");
});
it("discovers content tables dynamically", async () => {
// The test setup creates ec_post and ec_page
const snapshot = await generateSnapshot(db);
expect(snapshot.schema).toHaveProperty("ec_post");
expect(snapshot.schema).toHaveProperty("ec_page");
// Verify column discovery matches what we created
expect(snapshot.schema.ec_post.columns).toContain("title");
expect(snapshot.schema.ec_post.columns).toContain("content");
expect(snapshot.schema.ec_page.columns).toContain("title");
expect(snapshot.schema.ec_page.columns).toContain("content");
});
});