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,225 @@
/**
* Tests for importing WordPress reusable blocks as sections
*/
import type { Kysely } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { WxrPost } from "../../../src/cli/wxr/parser.js";
import type { Database } from "../../../src/database/types.js";
import { importReusableBlocksAsSections } from "../../../src/import/sections.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
describe("importReusableBlocksAsSections", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("should import wp_block posts as sections", async () => {
const posts: WxrPost[] = [
{
id: 100,
title: "Newsletter CTA",
postName: "newsletter-cta",
postType: "wp_block",
status: "publish",
content: `<!-- wp:heading {"level":3} -->
<h3>Subscribe to Our Newsletter</h3>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>Get the latest updates.</p>
<!-- /wp:paragraph -->`,
categories: [],
tags: [],
meta: new Map(),
},
{
id: 101,
title: "Hero Banner",
postName: "hero-banner",
postType: "wp_block",
status: "publish",
content: `<!-- wp:heading -->
<h2>Welcome</h2>
<!-- /wp:heading -->`,
categories: [],
tags: [],
meta: new Map(),
},
// Regular post - should be ignored
{
id: 1,
title: "Regular Post",
postName: "regular-post",
postType: "post",
status: "publish",
content: "<p>Hello</p>",
categories: [],
tags: [],
meta: new Map(),
},
];
const result = await importReusableBlocksAsSections(posts, db);
expect(result.sectionsCreated).toBe(2);
expect(result.sectionsSkipped).toBe(0);
expect(result.errors).toHaveLength(0);
// Verify sections were created
const sections = await db.selectFrom("_emdash_sections").selectAll().execute();
expect(sections).toHaveLength(2);
const newsletter = sections.find((s) => s.slug === "newsletter-cta");
expect(newsletter).toBeDefined();
expect(newsletter?.title).toBe("Newsletter CTA");
expect(newsletter?.source).toBe("import");
const hero = sections.find((s) => s.slug === "hero-banner");
expect(hero).toBeDefined();
expect(hero?.title).toBe("Hero Banner");
});
it("should skip existing sections by slug", async () => {
// Create existing section
await db
.insertInto("_emdash_sections")
.values({
id: "existing-1",
slug: "newsletter-cta",
title: "Existing Newsletter",
description: null,
keywords: null,
content: "[]",
preview_media_id: null,
source: "user",
theme_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.execute();
const posts: WxrPost[] = [
{
id: 100,
title: "Newsletter CTA",
postName: "newsletter-cta",
postType: "wp_block",
status: "publish",
content: "<p>New content</p>",
categories: [],
tags: [],
meta: new Map(),
},
{
id: 101,
title: "New Block",
postName: "new-block",
postType: "wp_block",
status: "publish",
content: "<p>New</p>",
categories: [],
tags: [],
meta: new Map(),
},
];
const result = await importReusableBlocksAsSections(posts, db);
expect(result.sectionsCreated).toBe(1);
expect(result.sectionsSkipped).toBe(1);
// Original title should be preserved
const existing = await db
.selectFrom("_emdash_sections")
.selectAll()
.where("slug", "=", "newsletter-cta")
.executeTakeFirst();
expect(existing?.title).toBe("Existing Newsletter");
});
it("should return empty result when no wp_block posts", async () => {
const posts: WxrPost[] = [
{
id: 1,
title: "Regular Post",
postName: "regular-post",
postType: "post",
status: "publish",
content: "<p>Hello</p>",
categories: [],
tags: [],
meta: new Map(),
},
];
const result = await importReusableBlocksAsSections(posts, db);
expect(result.sectionsCreated).toBe(0);
expect(result.sectionsSkipped).toBe(0);
expect(result.errors).toHaveLength(0);
});
it("should convert Gutenberg content to Portable Text", async () => {
const posts: WxrPost[] = [
{
id: 100,
title: "Test Block",
postName: "test-block",
postType: "wp_block",
status: "publish",
content: `<!-- wp:paragraph -->
<p>Hello <strong>world</strong>!</p>
<!-- /wp:paragraph -->`,
categories: [],
tags: [],
meta: new Map(),
},
];
await importReusableBlocksAsSections(posts, db);
const section = await db
.selectFrom("_emdash_sections")
.selectAll()
.where("slug", "=", "test-block")
.executeTakeFirst();
const content = JSON.parse(section?.content ?? "[]");
expect(content).toBeInstanceOf(Array);
expect(content.length).toBeGreaterThan(0);
expect(content[0]._type).toBe("block");
});
it("should generate slug from title if postName is missing", async () => {
const posts: WxrPost[] = [
{
id: 100,
title: "My Custom Block Title",
postName: undefined as unknown as string,
postType: "wp_block",
status: "publish",
content: "<p>Test</p>",
categories: [],
tags: [],
meta: new Map(),
},
];
await importReusableBlocksAsSections(posts, db);
const section = await db.selectFrom("_emdash_sections").selectAll().executeTakeFirst();
expect(section?.slug).toBe("my-custom-block-title");
});
});

View File

@@ -0,0 +1,791 @@
/**
* Tests for SSRF protection in import/ssrf.ts
*
* Covers:
* - IPv4-mapped IPv6 hex normalization (#58)
* - Private IP detection across all forms
* - validateExternalUrl blocking internal targets
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cloudflareDohResolver,
normalizeIPv6MappedToIPv4,
resolveAndValidateExternalUrl,
SsrfError,
validateExternalUrl,
} from "../../../src/import/ssrf.js";
describe("validateExternalUrl", () => {
// =========================================================================
// Basic validation
// =========================================================================
it("accepts valid external URLs", () => {
expect(validateExternalUrl("https://example.com")).toBeInstanceOf(URL);
expect(validateExternalUrl("https://wordpress.org/feed")).toBeInstanceOf(URL);
expect(validateExternalUrl("http://93.184.216.34/path")).toBeInstanceOf(URL);
});
it("rejects non-http schemes", () => {
expect(() => validateExternalUrl("ftp://example.com")).toThrow(SsrfError);
expect(() => validateExternalUrl("file:///etc/passwd")).toThrow(SsrfError);
expect(() => validateExternalUrl("javascript:alert(1)")).toThrow(SsrfError);
});
it("rejects invalid URLs", () => {
expect(() => validateExternalUrl("not a url")).toThrow(SsrfError);
expect(() => validateExternalUrl("")).toThrow(SsrfError);
});
// =========================================================================
// Blocked hostnames
// =========================================================================
it("blocks localhost", () => {
expect(() => validateExternalUrl("http://localhost/path")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://localhost:8080")).toThrow(SsrfError);
});
it("blocks metadata endpoints", () => {
expect(() => validateExternalUrl("http://metadata.google.internal/")).toThrow(SsrfError);
});
// =========================================================================
// IPv4 private ranges
// =========================================================================
it("blocks loopback (127.0.0.0/8)", () => {
expect(() => validateExternalUrl("http://127.0.0.1/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://127.255.255.255/")).toThrow(SsrfError);
});
it("blocks private 10.0.0.0/8", () => {
expect(() => validateExternalUrl("http://10.0.0.1/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://10.255.255.255/")).toThrow(SsrfError);
});
it("blocks private 172.16.0.0/12", () => {
expect(() => validateExternalUrl("http://172.16.0.1/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://172.31.255.255/")).toThrow(SsrfError);
});
it("blocks private 192.168.0.0/16", () => {
expect(() => validateExternalUrl("http://192.168.0.1/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://192.168.255.255/")).toThrow(SsrfError);
});
it("blocks link-local (169.254.0.0/16) including cloud metadata", () => {
expect(() => validateExternalUrl("http://169.254.169.254/latest/meta-data/")).toThrow(
SsrfError,
);
expect(() => validateExternalUrl("http://169.254.0.1/")).toThrow(SsrfError);
});
// =========================================================================
// IPv6 loopback
// =========================================================================
it("blocks IPv6 loopback [::1]", () => {
expect(() => validateExternalUrl("http://[::1]/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://[::1]:8080/")).toThrow(SsrfError);
});
// =========================================================================
// Issue #58: IPv4-mapped IPv6 in hex form
//
// The WHATWG URL parser normalizes [::ffff:127.0.0.1] to [::ffff:7f00:1].
// Before the fix, the hex form bypassed isPrivateIp() because the regex
// only matched dotted-decimal.
// =========================================================================
it("blocks IPv4-mapped IPv6 loopback in hex form [::ffff:7f00:1]", () => {
// This is the normalized form of [::ffff:127.0.0.1]
expect(() => validateExternalUrl("http://[::ffff:7f00:1]/evil")).toThrow(SsrfError);
});
it("blocks IPv4-mapped IPv6 cloud metadata [::ffff:a9fe:a9fe]", () => {
// This is the normalized form of [::ffff:169.254.169.254]
expect(() => validateExternalUrl("http://[::ffff:a9fe:a9fe]/latest/meta-data/")).toThrow(
SsrfError,
);
});
it("blocks IPv4-mapped IPv6 private 10.x [::ffff:a00:1]", () => {
// This is the normalized form of [::ffff:10.0.0.1]
expect(() => validateExternalUrl("http://[::ffff:a00:1]/")).toThrow(SsrfError);
});
it("blocks IPv4-mapped IPv6 private 192.168.x [::ffff:c0a8:1]", () => {
// This is the normalized form of [::ffff:192.168.0.1]
expect(() => validateExternalUrl("http://[::ffff:c0a8:1]/")).toThrow(SsrfError);
});
it("blocks IPv4-mapped IPv6 private 172.16.x [::ffff:ac10:1]", () => {
// This is the normalized form of [::ffff:172.16.0.1]
expect(() => validateExternalUrl("http://[::ffff:ac10:1]/")).toThrow(SsrfError);
});
it("blocks IPv4-mapped IPv6 in dotted-decimal form", () => {
// The dotted-decimal form should also be blocked (it worked before too)
// The URL parser normalizes this to hex, so this exercises the same path
expect(() => validateExternalUrl("http://[::ffff:127.0.0.1]/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://[::ffff:169.254.169.254]/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://[::ffff:10.0.0.1]/")).toThrow(SsrfError);
});
it("allows IPv4-mapped IPv6 for public IPs", () => {
// [::ffff:93.184.216.34] -> hex form after URL parsing
// 93 = 0x5d, 184 = 0xb8 -> 0x5db8
// 216 = 0xd8, 34 = 0x22 -> 0xd822
// So [::ffff:5db8:d822] should be allowed
expect(validateExternalUrl("http://[::ffff:5db8:d822]/")).toBeInstanceOf(URL);
});
// =========================================================================
// IPv4-compatible (deprecated) addresses: ::XXXX:XXXX (no ffff prefix)
//
// [::127.0.0.1] normalizes to [::7f00:1] which has no ffff prefix.
// Without the fix, these bypass all ffff-based checks.
// =========================================================================
it("blocks IPv4-compatible loopback [::7f00:1]", () => {
// Normalized form of [::127.0.0.1]
expect(() => validateExternalUrl("http://[::7f00:1]/evil")).toThrow(SsrfError);
});
it("blocks IPv4-compatible cloud metadata [::a9fe:a9fe]", () => {
// Normalized form of [::169.254.169.254]
expect(() => validateExternalUrl("http://[::a9fe:a9fe]/latest/meta-data/")).toThrow(SsrfError);
});
it("blocks IPv4-compatible private 10.x [::a00:1]", () => {
// Normalized form of [::10.0.0.1]
expect(() => validateExternalUrl("http://[::a00:1]/")).toThrow(SsrfError);
});
it("blocks IPv4-compatible private 192.168.x [::c0a8:1]", () => {
// Normalized form of [::192.168.0.1]
expect(() => validateExternalUrl("http://[::c0a8:1]/")).toThrow(SsrfError);
});
it("allows IPv4-compatible public IPs [::5db8:d822]", () => {
// 93.184.216.34 in hex
expect(validateExternalUrl("http://[::5db8:d822]/")).toBeInstanceOf(URL);
});
// =========================================================================
// NAT64 prefix: 64:ff9b::XXXX:XXXX
//
// [64:ff9b::127.0.0.1] normalizes to [64:ff9b::7f00:1].
// NAT64 gateways embed IPv4 in IPv6 using this well-known prefix.
// =========================================================================
it("blocks NAT64 loopback [64:ff9b::7f00:1]", () => {
expect(() => validateExternalUrl("http://[64:ff9b::7f00:1]/evil")).toThrow(SsrfError);
});
it("blocks NAT64 cloud metadata [64:ff9b::a9fe:a9fe]", () => {
expect(() => validateExternalUrl("http://[64:ff9b::a9fe:a9fe]/latest/meta-data/")).toThrow(
SsrfError,
);
});
it("blocks NAT64 private 10.x [64:ff9b::a00:1]", () => {
expect(() => validateExternalUrl("http://[64:ff9b::a00:1]/")).toThrow(SsrfError);
});
it("blocks NAT64 private 192.168.x [64:ff9b::c0a8:1]", () => {
expect(() => validateExternalUrl("http://[64:ff9b::c0a8:1]/")).toThrow(SsrfError);
});
it("allows NAT64 public IPs [64:ff9b::5db8:d822]", () => {
expect(validateExternalUrl("http://[64:ff9b::5db8:d822]/")).toBeInstanceOf(URL);
});
// =========================================================================
// IPv6 link-local and ULA
// =========================================================================
it("blocks IPv6 link-local (fe80::)", () => {
expect(() => validateExternalUrl("http://[fe80::1]/")).toThrow(SsrfError);
});
it("blocks IPv6 unique local (fc00::/fd00::)", () => {
expect(() => validateExternalUrl("http://[fc00::1]/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://[fd00::1]/")).toThrow(SsrfError);
});
it("blocks 0.0.0.0/8 range", () => {
expect(() => validateExternalUrl("http://0.0.0.0/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://0.0.0.1/")).toThrow(SsrfError);
});
// IPv4 literals with trailing dots. A single trailing dot is stripped by
// the WHATWG URL parser, but multiple trailing dots are preserved on
// .hostname. parseIpv4 rejects anything with a dot count != 4, so
// "127.0.0.1.." falls through to isPrivateIp's IPv6 fallback and
// returns false, bypassing the private-IP check. We must strip trailing
// dots before the private-IP check.
it("blocks IPv4 literals with trailing dots", () => {
expect(() => validateExternalUrl("http://127.0.0.1./")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://127.0.0.1../")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://169.254.169.254../")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://10.0.0.1../")).toThrow(SsrfError);
});
});
// =============================================================================
// normalizeIPv6MappedToIPv4 — direct unit tests (#58)
//
// This function converts IPv4-mapped/translated IPv6 hex addresses back to
// dotted-decimal IPv4 so they can be checked against private ranges. Without
// it, the WHATWG URL parser's hex normalization bypasses SSRF protection.
// =============================================================================
describe("normalizeIPv6MappedToIPv4", () => {
// =========================================================================
// Standard hex-form: ::ffff:XXXX:XXXX
// =========================================================================
it("converts loopback ::ffff:7f00:1 -> 127.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:7f00:1")).toBe("127.0.0.1");
});
it("converts cloud metadata ::ffff:a9fe:a9fe -> 169.254.169.254", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:a9fe:a9fe")).toBe("169.254.169.254");
});
it("converts private 10.x ::ffff:a00:1 -> 10.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:a00:1")).toBe("10.0.0.1");
});
it("converts private 192.168.x ::ffff:c0a8:1 -> 192.168.0.1", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:c0a8:1")).toBe("192.168.0.1");
});
it("converts private 172.16.x ::ffff:ac10:1 -> 172.16.0.1", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:ac10:1")).toBe("172.16.0.1");
});
it("converts public IP ::ffff:5db8:d822 -> 93.184.216.34", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:5db8:d822")).toBe("93.184.216.34");
});
// =========================================================================
// Edge values
// =========================================================================
it("converts ::ffff:0:0 -> 0.0.0.0", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:0:0")).toBe("0.0.0.0");
});
it("converts ::ffff:ffff:ffff -> 255.255.255.255", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:ffff:ffff")).toBe("255.255.255.255");
});
it("converts 4-digit hex groups correctly ::ffff:c612:e3a -> 198.18.14.58", () => {
// 0xc612 = 198*256 + 18 = 50706
// 0x0e3a = 14*256 + 58 = 3642
expect(normalizeIPv6MappedToIPv4("::ffff:c612:e3a")).toBe("198.18.14.58");
});
// =========================================================================
// Case insensitivity
// =========================================================================
it("handles uppercase hex digits", () => {
expect(normalizeIPv6MappedToIPv4("::FFFF:7F00:1")).toBe("127.0.0.1");
});
it("handles mixed case hex digits", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:A9FE:a9fe")).toBe("169.254.169.254");
});
// =========================================================================
// Bracket-wrapped form returns null (brackets stripped by caller)
// validateExternalUrl strips brackets before calling isPrivateIp,
// so normalizeIPv6MappedToIPv4 never receives bracketed input.
// =========================================================================
it("returns null for bracketed input (brackets stripped by caller)", () => {
expect(normalizeIPv6MappedToIPv4("[::ffff:7f00:1]")).toBeNull();
expect(normalizeIPv6MappedToIPv4("[::ffff:a9fe:a9fe]")).toBeNull();
});
// =========================================================================
// IPv4-translated (RFC 6052): ::ffff:0:XXXX:XXXX
// =========================================================================
it("converts translated form ::ffff:0:7f00:1 -> 127.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:0:7f00:1")).toBe("127.0.0.1");
});
it("converts translated form ::ffff:0:a9fe:a9fe -> 169.254.169.254", () => {
expect(normalizeIPv6MappedToIPv4("::ffff:0:a9fe:a9fe")).toBe("169.254.169.254");
});
// =========================================================================
// Fully expanded form: 0000:0000:0000:0000:0000:ffff:XXXX:XXXX
// =========================================================================
it("converts expanded form 0:0:0:0:0:ffff:7f00:1 -> 127.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("0:0:0:0:0:ffff:7f00:1")).toBe("127.0.0.1");
});
it("converts expanded form 0000:0000:0000:0000:0000:ffff:a9fe:a9fe -> 169.254.169.254", () => {
expect(normalizeIPv6MappedToIPv4("0000:0000:0000:0000:0000:ffff:a9fe:a9fe")).toBe(
"169.254.169.254",
);
});
it("converts expanded form with mixed zero lengths", () => {
expect(normalizeIPv6MappedToIPv4("0:00:000:0000:0:ffff:a00:1")).toBe("10.0.0.1");
});
// =========================================================================
// IPv4-compatible (deprecated) form: ::XXXX:XXXX (no ffff prefix)
// =========================================================================
it("converts IPv4-compatible loopback ::7f00:1 -> 127.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("::7f00:1")).toBe("127.0.0.1");
});
it("converts IPv4-compatible metadata ::a9fe:a9fe -> 169.254.169.254", () => {
expect(normalizeIPv6MappedToIPv4("::a9fe:a9fe")).toBe("169.254.169.254");
});
it("converts IPv4-compatible private ::a00:1 -> 10.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("::a00:1")).toBe("10.0.0.1");
});
it("converts IPv4-compatible public ::5db8:d822 -> 93.184.216.34", () => {
expect(normalizeIPv6MappedToIPv4("::5db8:d822")).toBe("93.184.216.34");
});
// =========================================================================
// NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX
// =========================================================================
it("converts NAT64 loopback 64:ff9b::7f00:1 -> 127.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("64:ff9b::7f00:1")).toBe("127.0.0.1");
});
it("converts NAT64 metadata 64:ff9b::a9fe:a9fe -> 169.254.169.254", () => {
expect(normalizeIPv6MappedToIPv4("64:ff9b::a9fe:a9fe")).toBe("169.254.169.254");
});
it("converts NAT64 private 64:ff9b::a00:1 -> 10.0.0.1", () => {
expect(normalizeIPv6MappedToIPv4("64:ff9b::a00:1")).toBe("10.0.0.1");
});
it("converts NAT64 public 64:ff9b::5db8:d822 -> 93.184.216.34", () => {
expect(normalizeIPv6MappedToIPv4("64:ff9b::5db8:d822")).toBe("93.184.216.34");
});
// =========================================================================
// Non-matching inputs -> null
// =========================================================================
it("returns null for plain IPv4", () => {
expect(normalizeIPv6MappedToIPv4("127.0.0.1")).toBeNull();
});
it("returns null for IPv6 loopback ::1", () => {
expect(normalizeIPv6MappedToIPv4("::1")).toBeNull();
});
it("returns null for regular IPv6 address", () => {
expect(normalizeIPv6MappedToIPv4("2001:db8::1")).toBeNull();
});
it("returns null for link-local IPv6", () => {
expect(normalizeIPv6MappedToIPv4("fe80::1")).toBeNull();
});
it("returns null for hostnames", () => {
expect(normalizeIPv6MappedToIPv4("example.com")).toBeNull();
expect(normalizeIPv6MappedToIPv4("localhost")).toBeNull();
});
it("returns null for empty string", () => {
expect(normalizeIPv6MappedToIPv4("")).toBeNull();
});
it("returns null for dotted-decimal mapped form (handled separately)", () => {
// ::ffff:127.0.0.1 uses the dotted-decimal regex, not hex normalization
expect(normalizeIPv6MappedToIPv4("::ffff:127.0.0.1")).toBeNull();
});
});
// =============================================================================
// Wildcard DNS services — hostname blocklist
//
// Services like nip.io map "127.0.0.1.nip.io" to 127.0.0.1. Without DNS
// resolution they pass validateExternalUrl since the hostname is neither an
// IP literal nor on the small internal-names list. Adding the apex domains
// to BLOCKED_HOSTNAMES catches the most widely-used rebinding tools without
// requiring a network round-trip.
// =============================================================================
describe("validateExternalUrl — wildcard DNS rebinding services", () => {
it("blocks nip.io and its subdomains", () => {
expect(() => validateExternalUrl("http://nip.io/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://127.0.0.1.nip.io/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://169.254.169.254.nip.io/latest/")).toThrow(SsrfError);
});
it("blocks sslip.io and its subdomains", () => {
expect(() => validateExternalUrl("http://sslip.io/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://127.0.0.1.sslip.io/")).toThrow(SsrfError);
});
it("blocks xip.io and its subdomains", () => {
expect(() => validateExternalUrl("http://xip.io/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://10.0.0.1.xip.io/")).toThrow(SsrfError);
});
it("blocks traefik.me and its subdomains", () => {
expect(() => validateExternalUrl("http://traefik.me/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://127.0.0.1.traefik.me/")).toThrow(SsrfError);
});
it("is case-insensitive for blocklisted hostnames", () => {
expect(() => validateExternalUrl("http://NIP.IO/")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://127.0.0.1.Nip.Io/")).toThrow(SsrfError);
});
// Trailing-dot FQDN form. The WHATWG URL parser preserves the dot on
// `.hostname`, so a naive exact-match or `.endsWith(suffix)` check misses
// these. Without explicit normalization, attackers can bypass both
// BLOCKED_HOSTNAMES and the suffix list by appending a single dot.
it("blocks trailing-dot FQDNs on the hostname blocklist", () => {
expect(() => validateExternalUrl("http://localhost./")).toThrow(SsrfError);
});
it("blocks trailing-dot FQDNs on the wildcard suffix list", () => {
expect(() => validateExternalUrl("http://nip.io./")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://127.0.0.1.nip.io./")).toThrow(SsrfError);
expect(() => validateExternalUrl("http://sslip.io./")).toThrow(SsrfError);
});
it("allows look-alike domains that are not on the blocklist", () => {
// Defensive: we should only block specific known services, not any
// domain that happens to contain "nip" or similar.
expect(validateExternalUrl("http://nippon.example.com/")).toBeInstanceOf(URL);
});
});
// =============================================================================
// resolveAndValidateExternalUrl — async DNS-aware validation
//
// Runs validateExternalUrl first (cheap pre-flight), then resolves the
// hostname via an injectable resolver and checks each returned IP against
// the private-range blocklist. Catches DNS rebinding attacks using domains
// the attacker controls (not just known public rebinding services).
// =============================================================================
describe("resolveAndValidateExternalUrl", () => {
// Helper: build a stubbed resolver that returns a fixed list of IPs.
function resolver(ips: string[]): (host: string) => Promise<string[]> {
return async () => ips;
}
// Helper: a resolver that fails. Used to assert fail-closed behaviour.
function failingResolver(error = new Error("DNS failure")) {
return async () => {
throw error;
};
}
it("accepts public IPs", async () => {
const url = await resolveAndValidateExternalUrl("https://example.com/", {
resolver: resolver(["93.184.216.34"]),
});
expect(url).toBeInstanceOf(URL);
expect(url.hostname).toBe("example.com");
});
it("rejects hostnames that resolve to loopback", async () => {
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver(["127.0.0.1"]),
}),
).rejects.toThrow(SsrfError);
});
it("rejects hostnames that resolve to cloud metadata IP", async () => {
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver(["169.254.169.254"]),
}),
).rejects.toThrow(SsrfError);
});
it("rejects hostnames that resolve to any RFC1918 address", async () => {
for (const ip of ["10.0.0.1", "172.16.0.1", "192.168.1.1"]) {
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver([ip]),
}),
).rejects.toThrow(SsrfError);
}
});
it("rejects if ANY resolved IP is private (multi-record DNS rebinding)", async () => {
// Attacker serves two A records; we must reject if either is private,
// not just the first one.
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver(["93.184.216.34", "127.0.0.1"]),
}),
).rejects.toThrow(SsrfError);
});
it("rejects IPv6 loopback in resolved records", async () => {
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver(["::1"]),
}),
).rejects.toThrow(SsrfError);
});
it("rejects IPv6 link-local in resolved records (any case)", async () => {
for (const ip of ["fe80::1", "FE80::1", "Fe80::abcd"]) {
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver([ip]),
}),
).rejects.toThrow(SsrfError);
}
});
it("rejects IPv6 unique-local in resolved records (any case)", async () => {
for (const ip of ["fc00::1", "FC00::1", "fd12:3456::1", "FD00::BEEF"]) {
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver([ip]),
}),
).rejects.toThrow(SsrfError);
}
});
it("rejects IPv4-mapped IPv6 loopback in resolved records", async () => {
await expect(
resolveAndValidateExternalUrl("https://attacker.example/", {
resolver: resolver(["::ffff:127.0.0.1"]),
}),
).rejects.toThrow(SsrfError);
});
it("accepts public IPv6 addresses", async () => {
const url = await resolveAndValidateExternalUrl("https://example.com/", {
resolver: resolver(["2606:4700:4700::1111"]),
});
expect(url).toBeInstanceOf(URL);
});
it("runs synchronous validateExternalUrl first (short-circuits on literal IP)", async () => {
// 127.0.0.1 as a literal hostname is caught by validateExternalUrl
// before any DNS lookup. Pass a resolver that would throw to prove it
// isn't called.
const r = failingResolver(new Error("should not be called"));
await expect(
resolveAndValidateExternalUrl("http://127.0.0.1/", { resolver: r }),
).rejects.toThrow(SsrfError);
});
it("fails closed when the resolver throws", async () => {
await expect(
resolveAndValidateExternalUrl("https://example.com/", {
resolver: failingResolver(),
}),
).rejects.toThrow(SsrfError);
});
it("rejects empty resolver result (hostname resolves to nothing)", async () => {
await expect(
resolveAndValidateExternalUrl("https://example.com/", {
resolver: resolver([]),
}),
).rejects.toThrow(SsrfError);
});
it("returns the parsed URL on success", async () => {
const url = await resolveAndValidateExternalUrl("https://example.com/path?q=1", {
resolver: resolver(["93.184.216.34"]),
});
expect(url.pathname).toBe("/path");
expect(url.searchParams.get("q")).toBe("1");
});
});
// =============================================================================
// cloudflareDohResolver — unit tests for the DoH parser
//
// Stubs globalThis.fetch to simulate various DoH responses. The resolver
// must:
// - return IPs from valid A and AAAA responses
// - treat NXDOMAIN (Status=3) as an empty result (legitimately non-existent)
// - fail closed on SERVFAIL (Status=2), REFUSED (Status=5), and other
// non-zero statuses, so that split-view DNS can't smuggle a private IP
// past the check by SERVFAIL'ing one record type
// - fail on HTTP errors
// - fail on malformed JSON or responses with missing fields
// =============================================================================
describe("cloudflareDohResolver", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function stubFetch(
responses: Record<"A" | "AAAA", { body?: unknown; status?: number; throws?: Error }>,
): void {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const type: "A" | "AAAA" = url.includes("type=AAAA") ? "AAAA" : "A";
const res = responses[type];
if (res.throws) throw res.throws;
return new Response(JSON.stringify(res.body ?? {}), { status: res.status ?? 200 });
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
}) as unknown as typeof globalThis.fetch;
}
it("returns A and AAAA records from a valid Status=0 response", async () => {
stubFetch({
A: { body: { Status: 0, Answer: [{ data: "93.184.216.34" }] } },
AAAA: { body: { Status: 0, Answer: [{ data: "2606:4700::1" }] } },
});
const ips = await cloudflareDohResolver("example.com");
expect(ips).toContain("93.184.216.34");
expect(ips).toContain("2606:4700::1");
});
it("treats NXDOMAIN (Status=3) as empty (legitimately no records)", async () => {
stubFetch({
A: { body: { Status: 3 } },
AAAA: { body: { Status: 3 } },
});
const ips = await cloudflareDohResolver("does-not-exist.example");
expect(ips).toEqual([]);
});
it("fails closed on SERVFAIL (Status=2)", async () => {
// Split-view attack: attacker authoritative NS returns SERVFAIL to
// Cloudflare's resolver but real records to the victim's resolver.
// If we silently treated SERVFAIL as empty, we'd combine whatever
// the other record type returned and call it "public" — bypassing
// the check.
stubFetch({
A: { body: { Status: 2 } },
AAAA: { body: { Status: 0, Answer: [{ data: "2606:4700::1" }] } },
});
await expect(cloudflareDohResolver("attacker.example")).rejects.toThrow();
});
it("fails closed on REFUSED (Status=5)", async () => {
stubFetch({
A: { body: { Status: 5 } },
AAAA: { body: { Status: 0, Answer: [{ data: "2606:4700::1" }] } },
});
await expect(cloudflareDohResolver("attacker.example")).rejects.toThrow();
});
it("fails closed on HTTP errors from the DoH endpoint", async () => {
stubFetch({
A: { status: 500 },
AAAA: { body: { Status: 0, Answer: [] } },
});
await expect(cloudflareDohResolver("example.com")).rejects.toThrow();
});
it("fails closed on malformed response bodies missing Status", async () => {
stubFetch({
A: { body: {} },
AAAA: { body: { Status: 0, Answer: [] } },
});
await expect(cloudflareDohResolver("example.com")).rejects.toThrow();
});
it("fails closed on network errors", async () => {
stubFetch({
A: { throws: new Error("network down") },
AAAA: { body: { Status: 0, Answer: [] } },
});
await expect(cloudflareDohResolver("example.com")).rejects.toThrow();
});
it("returns empty array when both A and AAAA return no records but Status=0", async () => {
stubFetch({
A: { body: { Status: 0, Answer: [] } },
AAAA: { body: { Status: 0, Answer: [] } },
});
const ips = await cloudflareDohResolver("example.com");
expect(ips).toEqual([]);
});
it("skips Answer entries without string data", async () => {
stubFetch({
A: {
body: {
Status: 0,
Answer: [{ data: "93.184.216.34" }, { data: 12345 }, {}, { notData: "foo" }],
},
},
AAAA: { body: { Status: 0, Answer: [] } },
});
const ips = await cloudflareDohResolver("example.com");
expect(ips).toEqual(["93.184.216.34"]);
});
// DoH responses often include CNAME records in the Answer chain alongside
// (or instead of) A/AAAA records. Their `data` field is a hostname, not
// an IP. If we return them, the validator's isPrivateIp check silently
// accepts them (parseIpv4 returns null → "not private" → pass).
it("filters CNAME-style hostname answers, keeping only IP literals", async () => {
stubFetch({
A: {
body: {
Status: 0,
Answer: [
{ data: "cdn.example.com." }, // CNAME target, not an IP
{ data: "93.184.216.34" }, // real A record
],
},
},
AAAA: {
body: {
Status: 0,
Answer: [{ data: "other.example.com." }, { data: "2606:4700::1" }],
},
},
});
const ips = await cloudflareDohResolver("example.com");
expect(ips).toEqual(["93.184.216.34", "2606:4700::1"]);
});
it("rejects a response that contains only CNAME strings", async () => {
stubFetch({
A: {
body: {
Status: 0,
Answer: [{ data: "target.example.com." }],
},
},
AAAA: { body: { Status: 0, Answer: [] } },
});
const ips = await cloudflareDohResolver("cname-only.example");
// No IPs at all — the caller should treat this as "could not resolve"
// and fail closed, not pretend the CNAME target is an address.
expect(ips).toEqual([]);
});
});

View File

@@ -0,0 +1,413 @@
/**
* Tests for WPML/Polylang auto-detection in WordPress plugin import source.
*
* Verifies that the probe() and analyze() methods correctly extract and
* surface i18n detection from the EmDash Exporter plugin's API responses.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { wordpressPluginSource } from "../../../src/import/sources/wordpress-plugin.js";
import { setDefaultDnsResolver } from "../../../src/import/ssrf.js";
// ─── Mock fetch ──────────────────────────────────────────────────────────────
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
// Bypass DoH so the fetch mock only sees the calls these tests model.
let previousResolver: ReturnType<typeof setDefaultDnsResolver> | undefined;
beforeAll(() => {
previousResolver = setDefaultDnsResolver(async () => ["93.184.216.34"]);
});
afterAll(() => {
setDefaultDnsResolver(previousResolver ?? null);
});
beforeEach(() => {
mockFetch.mockReset();
});
// ─── Fixtures ────────────────────────────────────────────────────────────────
/** Minimal valid probe response without i18n */
function makeProbeResponse(overrides: Record<string, unknown> = {}) {
return {
emdash_exporter: "1.0.0",
wordpress_version: "6.5",
site: {
title: "Test Site",
description: "A test site",
url: "https://example.com",
home: "https://example.com",
language: "en-US",
timezone: "UTC",
},
capabilities: {
application_passwords: true,
acf: false,
yoast: false,
rankmath: false,
},
post_types: [
{ name: "post", label: "Posts", count: 10 },
{ name: "page", label: "Pages", count: 5 },
],
media_count: 20,
endpoints: {},
auth_instructions: {
method: "application_passwords",
instructions: "Create an application password",
},
...overrides,
};
}
/** Minimal valid analyze response without i18n */
function makeAnalyzeResponse(overrides: Record<string, unknown> = {}) {
return {
site: { title: "Test Site", url: "https://example.com" },
post_types: [
{
name: "post",
label: "Posts",
label_singular: "Post",
total: 10,
by_status: { publish: 8, draft: 2 },
supports: { title: true, editor: true, thumbnail: true },
taxonomies: ["category", "post_tag"],
custom_fields: [],
hierarchical: false,
has_archive: true,
},
],
taxonomies: [
{
name: "category",
label: "Categories",
hierarchical: true,
term_count: 5,
object_types: ["post"],
},
{
name: "post_tag",
label: "Tags",
hierarchical: false,
term_count: 12,
object_types: ["post"],
},
],
authors: [
{ id: 1, login: "admin", email: "admin@example.com", display_name: "Admin", post_count: 10 },
],
attachments: { count: 20, by_type: { "image/jpeg": 15, "image/png": 5 } },
...overrides,
};
}
// ─── Probe tests ─────────────────────────────────────────────────────────────
describe("WordPress Plugin Source — i18n detection", () => {
describe("probe()", () => {
it("returns i18n when WPML is detected", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify(
makeProbeResponse({
i18n: {
plugin: "wpml",
default_locale: "en",
locales: ["en", "fr", "de"],
},
}),
),
{ status: 200 },
),
);
const result = await wordpressPluginSource.probe!("https://example.com");
expect(result).not.toBeNull();
expect(result!.i18n).toEqual({
plugin: "wpml",
defaultLocale: "en",
locales: ["en", "fr", "de"],
});
});
it("returns i18n when Polylang is detected", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify(
makeProbeResponse({
i18n: {
plugin: "polylang",
default_locale: "fr",
locales: ["fr", "en"],
},
}),
),
{ status: 200 },
),
);
const result = await wordpressPluginSource.probe!("https://example.com");
expect(result).not.toBeNull();
expect(result!.i18n).toEqual({
plugin: "polylang",
defaultLocale: "fr",
locales: ["fr", "en"],
});
});
it("returns undefined i18n when no multilingual plugin", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(makeProbeResponse()), { status: 200 }),
);
const result = await wordpressPluginSource.probe!("https://example.com");
expect(result).not.toBeNull();
expect(result!.i18n).toBeUndefined();
});
it("preserves other probe fields alongside i18n", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify(
makeProbeResponse({
i18n: {
plugin: "wpml",
default_locale: "en",
locales: ["en", "es"],
},
}),
),
{ status: 200 },
),
);
const result = await wordpressPluginSource.probe!("https://example.com");
expect(result).not.toBeNull();
expect(result!.sourceId).toBe("wordpress-plugin");
expect(result!.confidence).toBe("definite");
expect(result!.detected.platform).toBe("wordpress");
expect(result!.preview?.posts).toBe(10);
expect(result!.i18n?.plugin).toBe("wpml");
});
});
// ─── Analyze tests ───────────────────────────────────────────────────────
describe("analyze()", () => {
it("returns i18n when WPML is detected", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/analyze")) {
return new Response(
JSON.stringify(
makeAnalyzeResponse({
i18n: {
plugin: "wpml",
default_locale: "en",
locales: ["en", "fr", "de"],
},
}),
),
{ status: 200 },
);
}
// Media endpoint — return empty
return new Response(
JSON.stringify({ items: [], total: 0, pages: 0, page: 1, per_page: 100 }),
{ status: 200 },
);
});
const analysis = await wordpressPluginSource.analyze(
{ type: "url", url: "https://example.com", token: "test-token" },
{},
);
expect(analysis.i18n).toEqual({
plugin: "wpml",
defaultLocale: "en",
locales: ["en", "fr", "de"],
});
});
it("returns i18n when Polylang is detected", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/analyze")) {
return new Response(
JSON.stringify(
makeAnalyzeResponse({
i18n: {
plugin: "polylang",
default_locale: "fr",
locales: ["fr", "en", "de"],
},
}),
),
{ status: 200 },
);
}
return new Response(
JSON.stringify({ items: [], total: 0, pages: 0, page: 1, per_page: 100 }),
{ status: 200 },
);
});
const analysis = await wordpressPluginSource.analyze(
{ type: "url", url: "https://example.com", token: "test-token" },
{},
);
expect(analysis.i18n).toEqual({
plugin: "polylang",
defaultLocale: "fr",
locales: ["fr", "en", "de"],
});
});
it("returns undefined i18n when no multilingual plugin", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/analyze")) {
return new Response(JSON.stringify(makeAnalyzeResponse()), { status: 200 });
}
return new Response(
JSON.stringify({ items: [], total: 0, pages: 0, page: 1, per_page: 100 }),
{ status: 200 },
);
});
const analysis = await wordpressPluginSource.analyze(
{ type: "url", url: "https://example.com", token: "test-token" },
{},
);
expect(analysis.i18n).toBeUndefined();
});
});
// ─── Content fetch — locale/translationGroup passthrough ─────────────────
describe("fetchContent()", () => {
it("passes through locale and translationGroup from plugin posts", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [
{
id: 1,
post_type: "post",
status: "publish",
slug: "hello-world",
title: "Hello World",
content: "",
excerpt: "",
date: "2024-01-01T00:00:00",
date_gmt: "2024-01-01T00:00:00",
modified: "2024-01-01T00:00:00",
modified_gmt: "2024-01-01T00:00:00",
author: null,
parent: null,
menu_order: 0,
taxonomies: {},
meta: {},
locale: "en",
translation_group: "group-1",
},
{
id: 2,
post_type: "post",
status: "publish",
slug: "bonjour-le-monde",
title: "Bonjour le monde",
content: "",
excerpt: "",
date: "2024-01-01T00:00:00",
date_gmt: "2024-01-01T00:00:00",
modified: "2024-01-01T00:00:00",
modified_gmt: "2024-01-01T00:00:00",
author: null,
parent: null,
menu_order: 0,
taxonomies: {},
meta: {},
locale: "fr",
translation_group: "group-1",
},
],
total: 2,
pages: 1,
page: 1,
per_page: 100,
}),
{ status: 200 },
),
);
const items = [];
for await (const item of wordpressPluginSource.fetchContent(
{ type: "url", url: "https://example.com", token: "test-token" },
{ postTypes: ["post"] },
)) {
items.push(item);
}
expect(items).toHaveLength(2);
expect(items[0]!.locale).toBe("en");
expect(items[0]!.translationGroup).toBe("group-1");
expect(items[1]!.locale).toBe("fr");
expect(items[1]!.translationGroup).toBe("group-1");
});
it("returns undefined locale/translationGroup when not present", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
items: [
{
id: 1,
post_type: "post",
status: "publish",
slug: "hello",
title: "Hello",
content: "",
excerpt: "",
date: "2024-01-01T00:00:00",
date_gmt: "2024-01-01T00:00:00",
modified: "2024-01-01T00:00:00",
modified_gmt: "2024-01-01T00:00:00",
author: null,
parent: null,
menu_order: 0,
taxonomies: {},
meta: {},
},
],
total: 1,
pages: 1,
page: 1,
per_page: 100,
}),
{ status: 200 },
),
);
const items = [];
for await (const item of wordpressPluginSource.fetchContent(
{ type: "url", url: "https://example.com", token: "test-token" },
{ postTypes: ["post"] },
)) {
items.push(item);
}
expect(items).toHaveLength(1);
expect(items[0]!.locale).toBeUndefined();
expect(items[0]!.translationGroup).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,113 @@
/**
* Tests for WordPress import slug sanitization
*
* Regression test for emdash-cms/emdash#79: WordPress import crashes on
* collections with hyphens in slug (e.g. Elementor `elementor-hf`).
*
* WordPress post type slugs commonly use hyphens (e.g. `elementor-hf`,
* `my-custom-type`), but EmDash collection slugs require `[a-z][a-z0-9_]*`.
* The fix sanitizes all unknown post type slugs so they conform to the
* collection slug format, rather than trying to enumerate every plugin's
* internal post types.
*/
import { describe, expect, it } from "vitest";
import {
mapPostTypeToCollection,
sanitizeSlug,
} from "../../../src/astro/routes/api/import/wordpress/analyze.js";
describe("sanitizeSlug", () => {
it("replaces hyphens with underscores", () => {
expect(sanitizeSlug("elementor-hf")).toBe("elementor_hf");
});
it("replaces multiple hyphens", () => {
expect(sanitizeSlug("my-custom-type")).toBe("my_custom_type");
});
it("strips leading non-letter characters", () => {
expect(sanitizeSlug("123abc")).toBe("abc");
expect(sanitizeSlug("_foo")).toBe("foo");
});
it("leaves valid slugs unchanged", () => {
expect(sanitizeSlug("posts")).toBe("posts");
expect(sanitizeSlug("my_type")).toBe("my_type");
});
it("handles mixed invalid characters", () => {
expect(sanitizeSlug("my.custom" as string)).toBe("my_custom");
expect(sanitizeSlug("type with spaces" as string)).toBe("type_with_spaces");
});
it("falls back to 'imported' when result would be empty", () => {
expect(sanitizeSlug("123")).toBe("imported");
expect(sanitizeSlug("---")).toBe("imported");
expect(sanitizeSlug("_")).toBe("imported");
expect(sanitizeSlug("")).toBe("imported");
});
it("multiple degenerate slugs produce the same fallback (deduplicated during analysis)", () => {
// These all collapse to "imported" — analyzeWxr appends _1, _2, etc.
expect(sanitizeSlug("123")).toBe("imported");
expect(sanitizeSlug("456")).toBe("imported");
expect(sanitizeSlug("---")).toBe("imported");
});
it("handles leading hyphens in realistic WP slugs", () => {
expect(sanitizeSlug("-elementor-hf")).toBe("elementor_hf");
});
it("lowercases uppercase letters instead of dropping them", () => {
expect(sanitizeSlug("MyCustomType")).toBe("mycustomtype");
expect(sanitizeSlug("MyPortfolio")).toBe("myportfolio");
expect(sanitizeSlug("ALLCAPS")).toBe("allcaps");
});
it("prefixes reserved collection slugs with wp_", () => {
expect(sanitizeSlug("media")).toBe("wp_media");
expect(sanitizeSlug("content")).toBe("wp_content");
expect(sanitizeSlug("users")).toBe("wp_users");
expect(sanitizeSlug("revisions")).toBe("wp_revisions");
expect(sanitizeSlug("taxonomies")).toBe("wp_taxonomies");
expect(sanitizeSlug("options")).toBe("wp_options");
expect(sanitizeSlug("audit_logs")).toBe("wp_audit_logs");
});
});
describe("mapPostTypeToCollection", () => {
it("maps known WordPress post types", () => {
expect(mapPostTypeToCollection("post")).toBe("posts");
expect(mapPostTypeToCollection("page")).toBe("pages");
expect(mapPostTypeToCollection("product")).toBe("products");
});
it("maps attachment to media (known mapping bypasses reserved check)", () => {
expect(mapPostTypeToCollection("attachment")).toBe("media");
});
it("sanitizes unknown post types with hyphens (fixes #79)", () => {
expect(mapPostTypeToCollection("elementor-hf")).toBe("elementor_hf");
expect(mapPostTypeToCollection("my-custom-type")).toBe("my_custom_type");
});
it("sanitizes post types from other common plugins", () => {
// WooCommerce
expect(mapPostTypeToCollection("shop-order")).toBe("shop_order");
// ACF
expect(mapPostTypeToCollection("acf-field-group")).toBe("acf_field_group");
});
it("passes through valid unknown post types unchanged", () => {
expect(mapPostTypeToCollection("recipes")).toBe("recipes");
expect(mapPostTypeToCollection("portfolio")).toBe("portfolio");
});
it("prefixes reserved slugs that fall through to sanitize", () => {
// "content" is not in the known mapping, so it hits sanitizeSlug
expect(mapPostTypeToCollection("content")).toBe("wp_content");
expect(mapPostTypeToCollection("users")).toBe("wp_users");
});
});

View File

@@ -0,0 +1,91 @@
/**
* Regression test for #747: WordPress importer must clear the URL pattern
* cache after creating new collections so that public routing immediately
* resolves the new patterns. The original symptom of #747 (the execute
* step reading a stale DB-persisted manifest) is no longer possible —
* the manifest is built fresh per admin request and never cached — but
* the URL pattern cache is still per-isolate, and prepare->execute
* happens in two separate requests that may or may not share an isolate.
*/
import { describe, expect, it, vi } from "vitest";
import { POST } from "../../../src/astro/routes/api/import/wordpress/prepare.js";
import { setupTestDatabase } from "../../utils/test-db.js";
function buildRequest(body: unknown): Request {
return new Request("http://localhost/_emdash/api/import/wordpress/prepare", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-EmDash-Request": "1",
},
body: JSON.stringify(body),
});
}
function buildContext(emdash: any, user = { id: "test-user", role: 50 }) {
return {
request: buildRequest({
postTypes: [
{
name: "tablepress_table",
collection: "tablepress_table",
fields: [{ slug: "title", label: "Title", type: "string", required: true }],
},
],
}),
locals: { emdash, user },
};
}
describe("POST /api/import/wordpress/prepare", () => {
it("invalidates the URL pattern cache after creating a new collection (regression for #747)", async () => {
const db = await setupTestDatabase();
const invalidateUrlPatternCache = vi.fn();
const emdash = {
db,
handleContentCreate: vi.fn(),
invalidateUrlPatternCache,
};
const ctx = buildContext(emdash);
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion)
const response = await POST(ctx as any);
expect(response.status).toBe(200);
expect(invalidateUrlPatternCache).toHaveBeenCalledTimes(1);
});
it("does not invalidate the URL pattern cache when prepareImport makes no schema changes", async () => {
const db = await setupTestDatabase();
// Pre-create the collection so prepare finds nothing new to do.
const { SchemaRegistry } = await import("../../../src/schema/registry.js");
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "tablepress_table",
label: "Tablepress Tables",
labelSingular: "Tablepress Table",
});
await registry.createField("tablepress_table", {
slug: "title",
label: "Title",
type: "string",
});
const invalidateUrlPatternCache = vi.fn();
const emdash = {
db,
handleContentCreate: vi.fn(),
invalidateUrlPatternCache,
};
const ctx = buildContext(emdash);
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion)
const response = await POST(ctx as any);
expect(response.status).toBe(200);
expect(invalidateUrlPatternCache).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,139 @@
/**
* Tests for WordPress import prepare schema validation
*
* Regression test for #167: wpPrepareBody schema defined fields as z.record()
* but all producers (analyzer, admin UI) send an array of ImportFieldDef.
*/
import { describe, expect, it } from "vitest";
import { wpPrepareBody } from "../../../src/api/schemas/import.js";
describe("wpPrepareBody schema", () => {
it("accepts fields as an array of ImportFieldDef objects", () => {
const input = {
postTypes: [
{
name: "post",
collection: "posts",
fields: [
{
slug: "content",
label: "Content",
type: "portableText",
required: true,
searchable: true,
},
{
slug: "excerpt",
label: "Excerpt",
type: "text",
required: false,
},
],
},
],
};
const result = wpPrepareBody.safeParse(input);
expect(result.success).toBe(true);
});
it("accepts fields with optional searchable property", () => {
const input = {
postTypes: [
{
name: "page",
collection: "pages",
fields: [
{
slug: "featured_image",
label: "Featured Image",
type: "image",
required: false,
},
],
},
],
};
const result = wpPrepareBody.safeParse(input);
expect(result.success).toBe(true);
});
it("accepts postTypes without fields (optional)", () => {
const input = {
postTypes: [
{
name: "post",
collection: "posts",
},
],
};
const result = wpPrepareBody.safeParse(input);
expect(result.success).toBe(true);
});
it("rejects fields with missing required properties", () => {
const input = {
postTypes: [
{
name: "post",
collection: "posts",
fields: [
{
slug: "content",
// missing label, type, required
},
],
},
],
};
const result = wpPrepareBody.safeParse(input);
expect(result.success).toBe(false);
});
it("accepts multiple postTypes with fields", () => {
const input = {
postTypes: [
{
name: "post",
collection: "posts",
fields: [
{
slug: "content",
label: "Content",
type: "portableText",
required: true,
searchable: true,
},
],
},
{
name: "page",
collection: "pages",
fields: [
{
slug: "content",
label: "Content",
type: "portableText",
required: true,
searchable: true,
},
{
slug: "featured_image",
label: "Featured Image",
type: "image",
required: false,
},
],
},
],
};
const result = wpPrepareBody.safeParse(input);
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,167 @@
/**
* Tests for WXR import date handling
*
* Verifies that wxrPostToNormalizedItem correctly preserves post dates
* and publish status from WordPress exports.
*
* @see https://github.com/emdash-cms/emdash/issues/322
*/
import { describe, it, expect } from "vitest";
import type { WxrPost } from "../../../src/cli/wxr/parser.js";
import { wxrPostToNormalizedItem } from "../../../src/import/sources/wxr.js";
function makePost(overrides: Partial<WxrPost> = {}): WxrPost {
return {
categories: [],
tags: [],
meta: new Map(),
...overrides,
};
}
describe("wxrPostToNormalizedItem date handling", () => {
it("prefers postDateGmt over postDate for the date field", () => {
const post = makePost({
id: 1,
title: "Test Post",
postType: "post",
status: "publish",
postName: "test-post",
// postDate is site-local time (no timezone), postDateGmt is UTC
postDate: "2023-06-15 08:30:00",
postDateGmt: "2023-06-15 12:30:00",
pubDate: "Thu, 15 Jun 2023 12:30:00 +0000",
});
const item = wxrPostToNormalizedItem(post, new Map());
// Should use the GMT date, not the site-local date
expect(item.date.toISOString()).toBe("2023-06-15T12:30:00.000Z");
});
it("falls back to pubDate when postDateGmt is missing", () => {
const post = makePost({
id: 2,
title: "Post without GMT date",
postType: "post",
status: "publish",
postName: "no-gmt",
postDate: "2023-06-15 08:30:00",
pubDate: "Thu, 15 Jun 2023 12:30:00 +0000",
});
const item = wxrPostToNormalizedItem(post, new Map());
// pubDate is RFC 2822 with timezone, should parse correctly to UTC
expect(item.date.toISOString()).toBe("2023-06-15T12:30:00.000Z");
});
it("falls back to postDate when both postDateGmt and pubDate are missing", () => {
const post = makePost({
id: 3,
title: "Post with only local date",
postType: "post",
status: "draft",
postName: "local-only",
postDate: "2023-06-15 08:30:00",
});
const item = wxrPostToNormalizedItem(post, new Map());
// postDate is site-local, parsed as-is (imprecise but best available)
expect(item.date).toBeInstanceOf(Date);
expect(item.date.getTime()).not.toBeNaN();
});
it("defaults to current time when no dates are available", () => {
const before = Date.now();
const post = makePost({
id: 4,
title: "Post with no dates",
postType: "post",
status: "draft",
postName: "no-dates",
});
const item = wxrPostToNormalizedItem(post, new Map());
const after = Date.now();
expect(item.date.getTime()).toBeGreaterThanOrEqual(before);
expect(item.date.getTime()).toBeLessThanOrEqual(after);
});
it("ignores the WXR sentinel value '0000-00-00 00:00:00' for postDateGmt", () => {
const post = makePost({
id: 5,
title: "Draft with zero GMT date",
postType: "post",
status: "draft",
postName: "zero-gmt",
postDate: "2023-06-15 08:30:00",
postDateGmt: "0000-00-00 00:00:00",
pubDate: "Thu, 15 Jun 2023 12:30:00 +0000",
});
const item = wxrPostToNormalizedItem(post, new Map());
// Should NOT use the zero sentinel, should fall back to pubDate
expect(item.date.toISOString()).toBe("2023-06-15T12:30:00.000Z");
});
it("uses postModifiedGmt over postModified for the modified field", () => {
const post = makePost({
id: 6,
title: "Modified Post",
postType: "post",
status: "publish",
postName: "modified",
postDate: "2023-06-15 08:30:00",
postDateGmt: "2023-06-15 12:30:00",
postModified: "2023-07-01 10:00:00",
postModifiedGmt: "2023-07-01 14:00:00",
});
const item = wxrPostToNormalizedItem(post, new Map());
expect(item.modified).toBeInstanceOf(Date);
expect(item.modified!.toISOString()).toBe("2023-07-01T14:00:00.000Z");
});
it("returns undefined for modified when no modified dates exist", () => {
const post = makePost({
id: 7,
title: "Never Modified",
postType: "post",
status: "publish",
postName: "never-modified",
postDate: "2023-06-15 08:30:00",
postDateGmt: "2023-06-15 12:30:00",
});
const item = wxrPostToNormalizedItem(post, new Map());
expect(item.modified).toBeUndefined();
});
it("skips sentinel '0000-00-00 00:00:00' for postModifiedGmt and falls back", () => {
const post = makePost({
id: 8,
title: "Draft with zero modified GMT",
postType: "post",
status: "draft",
postName: "zero-modified-gmt",
postDate: "2023-06-15 08:30:00",
postDateGmt: "2023-06-15 12:30:00",
postModified: "2023-07-01 10:00:00",
postModifiedGmt: "0000-00-00 00:00:00",
});
const item = wxrPostToNormalizedItem(post, new Map());
// Should skip the sentinel and fall back to postModified
expect(item.modified).toBeInstanceOf(Date);
expect(item.modified!.getTime()).not.toBeNaN();
});
});