b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
276 lines
7.3 KiB
TypeScript
276 lines
7.3 KiB
TypeScript
import { ulid } from "ulidx";
|
|
import { it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import { handleContentCreate, handleContentUpdate } from "../../../src/api/handlers/content.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import {
|
|
describeEachDialect,
|
|
setupForDialect,
|
|
teardownForDialect,
|
|
type DialectTestContext,
|
|
} from "../../utils/test-db.js";
|
|
|
|
describeEachDialect("save-side media-field MIME validation", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
let pdfMediaId: string;
|
|
let zipMediaId: string;
|
|
|
|
beforeEach(async () => {
|
|
ctx = await setupForDialect(dialect);
|
|
|
|
// Create a posts collection with title and attachment fields
|
|
const registry = new SchemaRegistry(ctx.db);
|
|
await registry.createCollection({
|
|
slug: "posts",
|
|
label: "Posts",
|
|
labelSingular: "Post",
|
|
});
|
|
await registry.createField("posts", {
|
|
slug: "title",
|
|
label: "Title",
|
|
type: "string",
|
|
});
|
|
|
|
// Look up the collection id
|
|
const collection = await ctx.db
|
|
.selectFrom("_emdash_collections")
|
|
.select("id")
|
|
.where("slug", "=", "posts")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
// Add a `file` field to posts that allows only PDFs
|
|
await ctx.db
|
|
.insertInto("_emdash_fields")
|
|
.values({
|
|
id: ulid(),
|
|
collection_id: collection.id,
|
|
slug: "attachment",
|
|
label: "Attachment",
|
|
type: "file",
|
|
column_type: "TEXT",
|
|
required: 0,
|
|
unique: 0,
|
|
default_value: null,
|
|
validation: JSON.stringify({ allowedMimeTypes: ["application/pdf"] }),
|
|
widget: "file",
|
|
options: null,
|
|
sort_order: 10,
|
|
})
|
|
.execute();
|
|
|
|
// Add the column to ec_posts
|
|
await ctx.db.schema.alterTable("ec_posts").addColumn("attachment", "text").execute();
|
|
|
|
// Seed two media items
|
|
pdfMediaId = ulid();
|
|
zipMediaId = ulid();
|
|
await ctx.db
|
|
.insertInto("media")
|
|
.values([
|
|
{
|
|
id: pdfMediaId,
|
|
filename: "doc.pdf",
|
|
mime_type: "application/pdf",
|
|
size: 100,
|
|
width: null,
|
|
height: null,
|
|
alt: null,
|
|
caption: null,
|
|
storage_key: "doc.pdf",
|
|
content_hash: null,
|
|
blurhash: null,
|
|
dominant_color: null,
|
|
status: "ready",
|
|
author_id: null,
|
|
},
|
|
{
|
|
id: zipMediaId,
|
|
filename: "x.zip",
|
|
mime_type: "application/zip",
|
|
size: 100,
|
|
width: null,
|
|
height: null,
|
|
alt: null,
|
|
caption: null,
|
|
storage_key: "x.zip",
|
|
content_hash: null,
|
|
blurhash: null,
|
|
dominant_color: null,
|
|
status: "ready",
|
|
author_id: null,
|
|
},
|
|
])
|
|
.execute();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("accepts a PDF in a PDF-only field", async () => {
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p1",
|
|
data: {
|
|
title: "p1",
|
|
attachment: { id: pdfMediaId, provider: "local", filename: "doc.pdf" },
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects a zip in a PDF-only field on create", async () => {
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p2",
|
|
data: {
|
|
title: "p2",
|
|
attachment: { id: zipMediaId, provider: "local", filename: "x.zip" },
|
|
},
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD");
|
|
});
|
|
|
|
it("rejects a zip in a PDF-only field on update", async () => {
|
|
const created = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p3",
|
|
data: { title: "p3" },
|
|
});
|
|
if (!created.success) throw new Error("seed failed");
|
|
|
|
const result = await handleContentUpdate(ctx.db, "posts", created.data.item.id, {
|
|
data: {
|
|
attachment: { id: zipMediaId, provider: "local", filename: "x.zip" },
|
|
},
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD");
|
|
});
|
|
|
|
it("rejects external-provider ref when mimeType is present and does not match allowlist", async () => {
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p4",
|
|
data: {
|
|
title: "p4",
|
|
attachment: {
|
|
id: "ext-1",
|
|
provider: "s3",
|
|
filename: "remote.zip",
|
|
mimeType: "application/zip",
|
|
src: "https://example.com/remote.zip",
|
|
},
|
|
},
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD");
|
|
});
|
|
|
|
it("accepts external-provider ref when mimeType matches the allowlist", async () => {
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p4b",
|
|
data: {
|
|
title: "p4b",
|
|
attachment: {
|
|
id: "ext-2",
|
|
provider: "s3",
|
|
filename: "remote.pdf",
|
|
mimeType: "application/pdf",
|
|
src: "https://example.com/remote.pdf",
|
|
},
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("rejects external-provider ref with no mimeType when field is constrained", async () => {
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p4c",
|
|
data: {
|
|
title: "p4c",
|
|
attachment: {
|
|
id: "ext-3",
|
|
provider: "s3",
|
|
filename: "remote-unknown",
|
|
src: "https://example.com/remote-unknown",
|
|
},
|
|
},
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD");
|
|
});
|
|
|
|
it("rejects local-provider ref with non-string id when field is constrained", async () => {
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p4d",
|
|
data: {
|
|
title: "p4d",
|
|
attachment: { id: 123, provider: "local", filename: "doc.pdf" },
|
|
},
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD");
|
|
});
|
|
|
|
it("file/image field without allowedMimeTypes is not validated", async () => {
|
|
// Insert a second file field with no MIME restrictions (backwards-compat assertion)
|
|
const collection = await ctx.db
|
|
.selectFrom("_emdash_collections")
|
|
.select("id")
|
|
.where("slug", "=", "posts")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
await ctx.db
|
|
.insertInto("_emdash_fields")
|
|
.values({
|
|
id: ulid(),
|
|
collection_id: collection.id,
|
|
slug: "unconstrained",
|
|
label: "Unconstrained",
|
|
type: "file",
|
|
column_type: "TEXT",
|
|
required: 0,
|
|
unique: 0,
|
|
default_value: null,
|
|
validation: null,
|
|
widget: "file",
|
|
options: null,
|
|
sort_order: 20,
|
|
})
|
|
.execute();
|
|
|
|
await ctx.db.schema.alterTable("ec_posts").addColumn("unconstrained", "text").execute();
|
|
|
|
// Attaching a zip to the unconstrained field alongside a valid PDF in the
|
|
// constrained field — the save should succeed because unconstrained has no
|
|
// allowedMimeTypes.
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p5",
|
|
data: {
|
|
title: "p5",
|
|
attachment: { id: pdfMediaId, provider: "local", filename: "doc.pdf" },
|
|
unconstrained: { id: zipMediaId, provider: "local", filename: "x.zip" },
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("local media ID not found in DB returns INVALID_MIME_FOR_FIELD", async () => {
|
|
// Reference a made-up ULID that doesn't exist in the media table
|
|
const missingId = ulid();
|
|
const result = await handleContentCreate(ctx.db, "posts", {
|
|
slug: "p6",
|
|
data: {
|
|
title: "p6",
|
|
attachment: { id: missingId, provider: "local", filename: "ghost.pdf" },
|
|
},
|
|
});
|
|
expect(result.success).toBe(false);
|
|
if (result.success) return;
|
|
expect(result.error.code).toBe("INVALID_MIME_FOR_FIELD");
|
|
});
|
|
});
|