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:
225
packages/core/tests/unit/import/sections.test.ts
Normal file
225
packages/core/tests/unit/import/sections.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
791
packages/core/tests/unit/import/ssrf.test.ts
Normal file
791
packages/core/tests/unit/import/ssrf.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
413
packages/core/tests/unit/import/wordpress-plugin-i18n.test.ts
Normal file
413
packages/core/tests/unit/import/wordpress-plugin-i18n.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
139
packages/core/tests/unit/import/wp-prepare-schema.test.ts
Normal file
139
packages/core/tests/unit/import/wp-prepare-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
167
packages/core/tests/unit/import/wxr-date-handling.test.ts
Normal file
167
packages/core/tests/unit/import/wxr-date-handling.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user