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,140 @@
/**
* Rate-limit behaviour on POST /_emdash/api/comments/:collection/:contentId.
*
* Specifically covers the removal of the user-agent-hash fallback. Before,
* a submitter with no trusted IP could rotate their User-Agent string to
* get a fresh rate-limit bucket each time; the route now buckets all
* trusted-IP-less requests together into the shared "unknown" bucket.
*
* Operators behind a reverse proxy they control should set
* `trustedProxyHeaders` (or EMDASH_TRUSTED_PROXY_HEADERS) so this path
* isn't hit for legitimate traffic. Those tests live alongside the
* extractRequestMeta unit tests.
*/
import type { APIContext } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { POST as postComment } from "../../../src/astro/routes/api/comments/[collection]/[contentId]/index.js";
import { _resetTrustedProxyHeadersCache } from "../../../src/auth/trusted-proxy.js";
import type { Database } from "../../../src/database/types.js";
import { SchemaRegistry } from "../../../src/schema/registry.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
// Keep the env-derived trusted-header cache from leaking into this file
// (a stale EMDASH_TRUSTED_PROXY_HEADERS would route every UA to its own
// bucket and make the test pass for the wrong reason).
const ORIGINAL_TRUSTED_ENV = process.env.EMDASH_TRUSTED_PROXY_HEADERS;
function buildRequest(opts: { userAgent?: string; body: unknown }): Request {
return new Request("http://localhost/_emdash/api/comments/post/post-1", {
method: "POST",
headers: {
"content-type": "application/json",
...(opts.userAgent ? { "user-agent": opts.userAgent } : {}),
},
body: JSON.stringify(opts.body),
});
}
function buildContext(opts: { db: Kysely<Database>; request: Request }): APIContext {
return {
params: { collection: "post", contentId: "post-1" },
request: opts.request,
locals: {
emdash: {
db: opts.db,
config: {},
hooks: {
// Pass-through beforeCreate (returns the event unchanged).
runCommentBeforeCreate: async (event: unknown) => event,
// No moderator configured — returns null (route coerces to pending).
invokeExclusiveHook: async () => null,
runCommentAfterCreate: async () => undefined,
},
},
user: null,
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub for tests
} as unknown as APIContext;
}
describe("POST /comments — UA-hash rate-limit removal", () => {
let db: Kysely<Database>;
beforeEach(async () => {
delete process.env.EMDASH_TRUSTED_PROXY_HEADERS;
_resetTrustedProxyHeadersCache();
db = await setupTestDatabase();
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "post",
label: "Posts",
labelSingular: "Post",
commentsEnabled: true,
});
await registry.createField("post", { slug: "title", label: "Title", type: "string" });
// Create a published content row so the comment route can target it.
await db
.insertInto("ec_post" as never)
.values({
id: "post-1",
slug: "post-1",
status: "published",
published_at: new Date().toISOString(),
title: "Test post",
} as never)
.execute();
});
afterEach(async () => {
await teardownTestDatabase(db);
if (ORIGINAL_TRUSTED_ENV === undefined) {
delete process.env.EMDASH_TRUSTED_PROXY_HEADERS;
} else {
process.env.EMDASH_TRUSTED_PROXY_HEADERS = ORIGINAL_TRUSTED_ENV;
}
_resetTrustedProxyHeadersCache();
});
it("buckets no-trusted-IP requests together regardless of User-Agent", async () => {
// Submit 20 comments from different UA strings but without any
// trusted IP header. The limit for the "unknown" bucket is 20/10min.
// Before the fix, rotating UAs would give each request its own
// bucket; with the fix, they share the "unknown" bucket.
for (let i = 0; i < 20; i++) {
const res = await postComment(
buildContext({
db,
request: buildRequest({
userAgent: `Bot/${i}`,
body: {
authorName: "Spam",
authorEmail: "s@example.com",
body: `message ${i}`,
},
}),
}),
);
expect([200, 201]).toContain(res.status);
}
// 21st call with a fresh UA must still hit the shared bucket and
// get rate-limited.
const limitedRes = await postComment(
buildContext({
db,
request: buildRequest({
userAgent: "Bot/fresh",
body: {
authorName: "Spam",
authorEmail: "s@example.com",
body: "one more",
},
}),
}),
);
expect(limitedRes.status).toBe(429);
});
});

View File

@@ -0,0 +1,188 @@
/**
* Success-path coverage for the setup nonce cookie.
*
* The sibling file `setup-admin-nonce.test.ts` covers the negative
* paths (missing cookie, mismatched cookie, rotation) by driving
* /setup/admin/verify with a bogus credential that fails at the
* WebAuthn step. That harness can't exercise the *successful* verify
* path — real WebAuthn verification requires a live authenticator.
*
* This file stubs `verifyRegistrationResponse` with a fake that
* returns synthetic credential material so we can reach the code
* after the nonce gate: user creation, passkey registration, setup
* completion, and — the property we actually care about — deletion
* of the nonce cookie.
*
* `registerPasskey` is left real; it only talks to the Kysely
* adapter against the in-memory test DB.
*/
import type { APIContext, AstroCookies } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@emdash-cms/auth/passkey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@emdash-cms/auth/passkey")>();
return {
...actual,
verifyRegistrationResponse: vi.fn(async () => ({
credentialId: "fake-credential-id",
publicKey: new Uint8Array([1, 2, 3, 4]),
counter: 0,
deviceType: "singleDevice" as const,
backedUp: false,
transports: [],
})),
};
});
// Deferred so vi.mock applies before the route modules evaluate.
type AdminRoute = typeof import("../../../src/astro/routes/api/setup/admin.js");
type AdminVerifyRoute = typeof import("../../../src/astro/routes/api/setup/admin-verify.js");
let postAdmin: AdminRoute["POST"];
let postAdminVerify: AdminVerifyRoute["POST"];
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
interface CookieRecord {
value: string;
options: Record<string, unknown>;
deleted?: boolean;
}
interface CookieJar {
jar: Map<string, CookieRecord>;
cookies: AstroCookies;
}
function createCookieJar(initial: Record<string, string> = {}): CookieJar {
const jar = new Map<string, CookieRecord>();
for (const [name, value] of Object.entries(initial)) {
jar.set(name, { value, options: {} });
}
const cookies = {
get(name: string) {
const record = jar.get(name);
if (!record || record.deleted) return undefined;
return { value: record.value };
},
set(name: string, value: string, options: Record<string, unknown> = {}) {
jar.set(name, { value, options });
},
delete(name: string, options: Record<string, unknown> = {}) {
const existing = jar.get(name);
jar.set(name, {
value: existing?.value ?? "",
options: { ...existing?.options, ...options },
deleted: true,
});
},
has(name: string) {
const record = jar.get(name);
return !!record && !record.deleted;
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as AstroCookies;
return { jar, cookies };
}
function buildAdminRequest(body: unknown): Request {
return new Request("http://localhost/_emdash/api/setup/admin", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function buildVerifyRequest(body: unknown): Request {
return new Request("http://localhost/_emdash/api/setup/admin/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function buildContext(db: Kysely<Database>, request: Request, cookies: AstroCookies): APIContext {
return {
params: {},
url: new URL(request.url),
request,
cookies,
locals: {
emdash: {
db,
config: {},
storage: undefined,
},
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as APIContext;
}
const adminBody = { email: "real@admin.example", name: "Real Admin" };
// Any object that passes setupAdminVerifyBody — the actual WebAuthn
// verification is mocked out, so the fields don't need to parse as
// valid authenticator data.
const fakeCredential = {
credential: {
id: "fake-credential-id",
rawId: "fake-credential-id",
type: "public-key" as const,
response: {
clientDataJSON: "AA",
attestationObject: "AA",
},
},
};
describe("POST /setup/admin/verify — success path clears nonce cookie", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
({ POST: postAdmin } = await import("../../../src/astro/routes/api/setup/admin.js"));
({ POST: postAdminVerify } =
await import("../../../src/astro/routes/api/setup/admin-verify.js"));
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("deletes the setup nonce cookie and marks setup complete when verify succeeds", async () => {
// 1. Start admin setup — mints the nonce and drops the cookie.
const { jar, cookies } = createCookieJar();
const adminRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
expect(adminRes.status).toBe(200);
const setCookie = jar.get("emdash_setup_nonce");
expect(setCookie).toBeDefined();
expect(setCookie!.deleted).toBeFalsy();
// 2. Verify with the mocked-out WebAuthn check. The nonce gate
// runs first (real code path), then the stub returns a
// synthetic credential and the route creates the user.
const verifyRes = await postAdminVerify(
buildContext(db, buildVerifyRequest(fakeCredential), cookies),
);
expect(verifyRes.status).toBe(200);
// 3. Cookie should now be deleted. The deletion must be
// scoped to /_emdash/ so it actually supersedes the cookie
// the browser holds.
const afterVerify = jar.get("emdash_setup_nonce");
expect(afterVerify?.deleted).toBe(true);
expect(afterVerify?.options.path).toBe("/_emdash/");
// 4. Setup state is cleared and setup_complete is set.
const options = new OptionsRepository(db);
const setupState = await options.get("emdash:setup_state");
expect(setupState).toBeNull();
const setupComplete = await options.get("emdash:setup_complete");
expect(setupComplete).toBe(true);
});
});

View File

@@ -0,0 +1,265 @@
/**
* POST /_emdash/api/setup/admin mints a per-session nonce, sets it as an
* HttpOnly cookie scoped to /_emdash/, and stores it inside
* `emdash:setup_state`. POST /_emdash/api/setup/admin/verify must then
* present the same cookie value.
*
* Without this binding, an unauthenticated attacker could call
* /setup/admin during the setup window and overwrite the legitimate
* admin's email; when the admin then completes passkey verification,
* the user account would be created with the attacker's address.
*/
import type { APIContext, AstroCookies } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { POST as postAdminVerify } from "../../../src/astro/routes/api/setup/admin-verify.js";
import { POST as postAdmin } from "../../../src/astro/routes/api/setup/admin.js";
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
interface CookieRecord {
value: string;
options: Record<string, unknown>;
}
interface CookieJar {
jar: Map<string, CookieRecord>;
cookies: AstroCookies;
}
/**
* Minimal in-memory implementation of Astro's `AstroCookies`. Tests
* compose two contexts (admin, verify) and carry cookies between them.
*/
function createCookieJar(initial: Record<string, string> = {}): CookieJar {
const jar = new Map<string, CookieRecord>();
for (const [name, value] of Object.entries(initial)) {
jar.set(name, { value, options: {} });
}
const cookies = {
get(name: string) {
const record = jar.get(name);
if (!record) return undefined;
return { value: record.value };
},
set(name: string, value: string, options: Record<string, unknown> = {}) {
jar.set(name, { value, options });
},
delete(name: string) {
jar.delete(name);
},
has(name: string) {
return jar.has(name);
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as AstroCookies;
return { jar, cookies };
}
function buildAdminRequest(body: unknown): Request {
return new Request("http://localhost/_emdash/api/setup/admin", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function buildVerifyRequest(body: unknown): Request {
return new Request("http://localhost/_emdash/api/setup/admin/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function buildContext(db: Kysely<Database>, request: Request, cookies: AstroCookies): APIContext {
return {
params: {},
url: new URL(request.url),
request,
cookies,
locals: {
emdash: {
db,
config: {},
storage: undefined,
},
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as APIContext;
}
const adminBody = { email: "real@admin.example", name: "Real Admin" };
const attackerBody = { email: "attacker@evil.example", name: "Attacker" };
// A bogus passkey credential — verify will fail at the WebAuthn step,
// but only AFTER the nonce check. We're asserting on the nonce gate, not
// the eventual passkey result.
const bogusCredential = {
credential: {
id: "AA",
rawId: "AA",
type: "public-key" as const,
response: {
clientDataJSON: "AA",
attestationObject: "AA",
},
},
};
describe("POST /setup/admin — session nonce binding", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("sets a HttpOnly nonce cookie on the response and stores it with setup state", async () => {
const { jar, cookies } = createCookieJar();
const res = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
expect(res.status).toBe(200);
const cookie = jar.get("emdash_setup_nonce");
expect(cookie).toBeDefined();
// 32 bytes base64url-encoded with no padding = 43 chars. Lock the
// shape so accidental entropy changes trip this test.
expect(cookie!.value).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(cookie!.options.httpOnly).toBe(true);
// The route sets sameSite: "strict" deliberately — this is the
// property that prevents cross-site submission of the cookie.
// Allowing "lax" here would silently accept a regression.
expect(cookie!.options.sameSite).toBe("strict");
expect(cookie!.options.path).toBe("/_emdash/");
const options = new OptionsRepository(db);
const setupState = await options.get<{ email: string; nonce: string }>("emdash:setup_state");
expect(setupState).toBeDefined();
expect(setupState?.email).toBe("real@admin.example");
expect(setupState?.nonce).toBe(cookie!.value);
});
it("sets Secure on the nonce cookie when the public origin is HTTPS, even if the internal request URL is HTTP", async () => {
// Simulates a TLS-terminating reverse proxy: browser speaks
// https:// to the proxy, proxy speaks http:// to the app. The
// cookie must still be marked Secure so it's never sent over a
// plain-text channel on the public side.
const { jar, cookies } = createCookieJar();
const request = buildAdminRequest(adminBody);
const ctx = buildContext(db, request, cookies);
// Force the "internal" view to be HTTP…
(ctx as { url: URL }).url = new URL("http://internal.localhost/_emdash/api/setup/admin");
// …while config.siteUrl declares the public HTTPS origin.
(ctx.locals as { emdash: { config: { siteUrl: string } } }).emdash.config = {
siteUrl: "https://public.example.com",
};
const res = await postAdmin(ctx);
expect(res.status).toBe(200);
const cookie = jar.get("emdash_setup_nonce");
expect(cookie).toBeDefined();
expect(cookie!.options.secure).toBe(true);
});
it("omits Secure on the nonce cookie when the public origin is HTTP (local dev)", async () => {
// Mirror of the test above: a plain http://localhost deployment
// must not set Secure (Chromium would drop the cookie entirely).
const { jar, cookies } = createCookieJar();
const res = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
expect(res.status).toBe(200);
const cookie = jar.get("emdash_setup_nonce");
expect(cookie).toBeDefined();
expect(cookie!.options.secure).toBe(false);
});
it("rejects /admin/verify when no nonce cookie is present", async () => {
// Legitimate admin call mints the nonce.
const { cookies: adminCookies } = createCookieJar();
const adminRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), adminCookies));
expect(adminRes.status).toBe(200);
// Attacker calls verify without the cookie.
const { cookies: noCookies } = createCookieJar();
const verifyRes = await postAdminVerify(
buildContext(db, buildVerifyRequest(bogusCredential), noCookies),
);
expect(verifyRes.status).toBe(400);
const body = (await verifyRes.json()) as { error?: { code?: string } };
expect(body.error?.code).toBe("INVALID_STATE");
});
it("rejects /admin/verify when the nonce cookie does not match the stored nonce", async () => {
const { cookies: adminCookies } = createCookieJar();
const adminRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), adminCookies));
expect(adminRes.status).toBe(200);
// Attacker presents a forged cookie with a guessed value.
const { cookies: attackerCookies } = createCookieJar({
emdash_setup_nonce: "obviously-wrong-value",
});
const verifyRes = await postAdminVerify(
buildContext(db, buildVerifyRequest(bogusCredential), attackerCookies),
);
expect(verifyRes.status).toBe(400);
const body = (await verifyRes.json()) as { error?: { code?: string } };
expect(body.error?.code).toBe("INVALID_STATE");
});
it("blocks the email-hijack attack: attacker overwrites setup_state but cannot complete verify", async () => {
// 1. Legitimate admin starts setup.
const { jar: adminJar, cookies: adminCookies } = createCookieJar();
const firstRes = await postAdmin(buildContext(db, buildAdminRequest(adminBody), adminCookies));
expect(firstRes.status).toBe(200);
const adminNonce = adminJar.get("emdash_setup_nonce")!.value;
// 2. Attacker (different browser, no cookie) calls /setup/admin to
// overwrite the email. With the fix this also rotates the nonce,
// invalidating the legitimate admin's session.
const { jar: attackerJar, cookies: attackerCookies } = createCookieJar();
const attackerRes = await postAdmin(
buildContext(db, buildAdminRequest(attackerBody), attackerCookies),
);
expect(attackerRes.status).toBe(200);
const attackerNonce = attackerJar.get("emdash_setup_nonce")!.value;
expect(attackerNonce).not.toBe(adminNonce);
// 3. Legitimate admin completes verify with their original cookie.
// This must NOT succeed, because the stored nonce has rotated.
const verifyRes = await postAdminVerify(
buildContext(db, buildVerifyRequest(bogusCredential), adminCookies),
);
expect(verifyRes.status).toBe(400);
const body = (await verifyRes.json()) as { error?: { code?: string } };
expect(body.error?.code).toBe("INVALID_STATE");
});
it("allows a legitimate admin to retry /setup/admin and reuse the new cookie", async () => {
// First call mints nonce A.
const { jar, cookies } = createCookieJar();
const first = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
expect(first.status).toBe(200);
const nonceA = jar.get("emdash_setup_nonce")!.value;
// Same admin retries (e.g. corrected typo). Nonce rotates, cookie
// updates in the same jar — they continue with the new value.
const second = await postAdmin(buildContext(db, buildAdminRequest(adminBody), cookies));
expect(second.status).toBe(200);
const nonceB = jar.get("emdash_setup_nonce")!.value;
expect(nonceB).not.toBe(nonceA);
const options = new OptionsRepository(db);
const setupState = await options.get<{ nonce: string }>("emdash:setup_state");
expect(setupState?.nonce).toBe(nonceB);
});
});

View File

@@ -0,0 +1,161 @@
/**
* POST /_emdash/api/setup writes `emdash:site_url` once. Subsequent calls
* to the setup endpoint (during the multi-step wizard, before
* `emdash:setup_complete` is true) must not overwrite it.
*
* Without this, a spoofed Host header on any follow-up POST during the
* setup window could poison the site URL used in auth emails.
*
* The primary defence (config.siteUrl / EMDASH_SITE_URL env) was added
* earlier; this is the last-line lock for deployments that rely on the
* request-origin fallback.
*/
import type { APIContext } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Stub the seed virtual module that loadSeed() imports at runtime. Without
// this the setup route errors out before reaching the site_url write.
vi.mock("virtual:emdash/seed", () => ({
seed: {
version: "1",
settings: {},
collections: [],
},
userSeed: null,
}));
import { POST as postSetup } from "../../../src/astro/routes/api/setup/index.js";
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
function buildRequest(host: string, body: unknown): Request {
return new Request(`http://${host}/_emdash/api/setup`, {
method: "POST",
headers: {
"content-type": "application/json",
host,
},
body: JSON.stringify(body),
});
}
function buildContext(db: Kysely<Database>, request: Request): APIContext {
return {
params: {},
url: new URL(request.url),
request,
locals: {
emdash: {
db,
config: {},
storage: undefined,
},
},
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub
} as unknown as APIContext;
}
describe("POST /setup — site_url write-once lock", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("stores site_url from the first request", async () => {
const res = await postSetup(
buildContext(
db,
buildRequest("real-site.example", { title: "My Site", includeContent: false }),
),
);
expect(res.status).toBe(200);
const options = new OptionsRepository(db);
expect(await options.get("emdash:site_url")).toBe("http://real-site.example");
});
it("does not overwrite site_url when a later setup call arrives with a spoofed Host", async () => {
// First call — legitimate admin on the real host.
const first = await postSetup(
buildContext(
db,
buildRequest("real-site.example", { title: "My Site", includeContent: false }),
),
);
expect(first.status).toBe(200);
// Attacker sends a second setup call with a spoofed Host header
// before the admin has completed the final step. Without the lock,
// the stored site_url would be overwritten.
const second = await postSetup(
buildContext(
db,
buildRequest("attacker.example", { title: "My Site", includeContent: false }),
),
);
expect(second.status).toBe(200);
const options = new OptionsRepository(db);
expect(await options.get("emdash:site_url")).toBe("http://real-site.example");
});
it("is atomic under concurrent setup POSTs with different Hosts", async () => {
// Two concurrent callers observe an empty site_url and race to
// write. Without DB-level write-once semantics, the last writer
// wins and the legitimate host can still be replaced.
const [a, b] = await Promise.all([
postSetup(
buildContext(
db,
buildRequest("real-site.example", { title: "My Site", includeContent: false }),
),
),
postSetup(
buildContext(
db,
buildRequest("attacker.example", { title: "My Site", includeContent: false }),
),
),
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
const options = new OptionsRepository(db);
const stored = await options.get("emdash:site_url");
// Whichever call won the race must now stick — a third caller must
// not be able to overwrite it.
expect(["http://real-site.example", "http://attacker.example"]).toContain(stored);
const third = await postSetup(
buildContext(db, buildRequest("other.example", { title: "My Site", includeContent: false })),
);
expect(third.status).toBe(200);
expect(await options.get("emdash:site_url")).toBe(stored);
});
it("does not overwrite a legitimately-stored empty string", async () => {
// Defence-in-depth: if site_url was somehow stored as "" (e.g.
// manual DB edit, legacy data, test fixture), the guard must treat
// it as present, not missing.
const options = new OptionsRepository(db);
await options.set("emdash:site_url", "");
const res = await postSetup(
buildContext(
db,
buildRequest("attacker.example", { title: "My Site", includeContent: false }),
),
);
expect(res.status).toBe(200);
expect(await options.get("emdash:site_url")).toBe("");
});
});