Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/dangerously-enable-test-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"emdash": minor
---

Adds `dangerouslyEnableTestHooks` to the `emdash()` integration so end-to-end suites can run against a production build. It turns on the development-only endpoints (`/_emdash/api/setup/dev-bypass`, `/_emdash/api/auth/dev-bypass`, `/_emdash/api/setup/dev-reset`, `/_emdash/api/dev/emails`), the console email provider, and plain-HTTP localhost registry URLs, which otherwise work only under `astro dev`.

```js
emdash({
database: sqlite({ url: "file:./test.db" }),
dangerouslyEnableTestHooks: true,
});
```

The option is read at build time and defaults to `false`. Anyone who can reach a site built with it can sign in as an administrator, so use it only for test builds and never deploy one. `astro build` prints a warning when it is on. Builds without it behave as before.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,8 @@ jobs:
id: node-e2e
if: needs.change-plan.outputs.e2e_node == 'true'
run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
EMDASH_E2E_BUILD: production
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure() && steps.node-e2e.outcome == 'failure'
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ uploads/
!packages/core/tests/integration/fixture/.emdash/
!e2e/fixture/.emdash/
!e2e/fixture-cloudflare/.emdash/
e2e/fixture*/.emdash/migrations.json
!templates/*/.emdash/seed.json

# macOS-specific files
Expand Down
6 changes: 6 additions & 0 deletions e2e/fixture/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,18 @@ export default defineConfig({
marketplace: marketplaceUrl,
registry: registryUrl,
sandboxRunner: "@emdash-cms/sandbox-workerd",
// The suite signs in and resets the site through the dev-only
// endpoints, which a production build otherwise compiles out.
dangerouslyEnableTestHooks: true,
}),
],
i18n: {
defaultLocale: "en",
locales: ["en", "fr", "es"],
fallback: { fr: "en", es: "en" },
// The fixture serves locales from its own `[locale]` routes. Automatic
// routing makes production builds add fallback routes that shadow them.
routing: "manual",
},
devToolbar: { enabled: false },
vite: {
Expand Down
3 changes: 2 additions & 1 deletion e2e/fixture/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"astro": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"sharp": "^0.35.4"
}
}
5 changes: 5 additions & 0 deletions e2e/fixture/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineMiddleware } from "astro:middleware";

// Manual i18n routing requires a middleware file; the fixture's `[locale]`
// routes handle locale prefixes themselves.
export const onRequest = defineMiddleware((_context, next) => next());
10 changes: 10 additions & 0 deletions e2e/fixture/src/outer-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { readFileSync } from "node:fs";

import { defineMiddleware } from "astro:middleware";
import { installRegistryAuthoritativeFixture } from "emdash/internal/testing/registry";
import { getRequestContext } from "emdash/request-context";

// A built server never runs astro.config.mjs, which installs the fixture for
// `astro dev`, so install it here at startup too.
const registryFixturePath = process.env.EMDASH_REGISTRY_FIXTURE;
if (registryFixturePath) {
installRegistryAuthoritativeFixture(JSON.parse(readFileSync(registryFixturePath, "utf8")));
}

const FINALIZER_NONCE = "outer-finalizer";

function preNextState(locals: App.Locals): string {
Expand Down
108 changes: 75 additions & 33 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { execFile, spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -46,6 +46,11 @@ const TARGETS: Record<string, Target> = {
};

const TARGET = TARGETS[process.env.EMDASH_E2E_TARGET ?? "node"] ?? TARGETS.node!;
/**
* `EMDASH_E2E_BUILD=production` builds the fixture for production and serves
* the built output instead of running `astro dev`. Node target only.
*/
const PRODUCTION_BUILD = process.env.EMDASH_E2E_BUILD === "production";
const FIXTURE_DIR = TARGET.fixtureDir;
const CLI_BINARY = resolve(ROOT, "packages/core/dist/cli/index.mjs");
const PORT = 4444;
Expand Down Expand Up @@ -336,7 +341,19 @@ export default async function globalSetup(): Promise<void> {
// the database — source files stay at their real paths so Astro's virtual
// module resolver can find the compile metadata.
const workDir = FIXTURE_DIR;
const tempDataDir = mkdtempSync(join(tmpdir(), "emdash-pw-"));
// A production build bakes the database path into its output, so it needs a
// path that stays the same between runs.
let tempDataDir: string;
if (PRODUCTION_BUILD) {
if (TARGET !== TARGETS.node) {
throw new Error("EMDASH_E2E_BUILD=production only supports the node target");
}
tempDataDir = join(tmpdir(), "emdash-pw-production");
rmSync(tempDataDir, { recursive: true, force: true });
mkdirSync(tempDataDir, { recursive: true });
} else {
tempDataDir = mkdtempSync(join(tmpdir(), "emdash-pw-"));
}
const dbPath = join(tempDataDir, "test.db");
const registryFixturePath = join(tempDataDir, "registry-fixture.json");
writeFileSync(registryFixturePath, JSON.stringify(registryFixture));
Expand All @@ -352,22 +369,43 @@ export default async function globalSetup(): Promise<void> {
rmSync(join(FIXTURE_DIR, ".wrangler"), { recursive: true, force: true });
}

// 2. Start dev server (with marketplace URL injected via env)
// 2. Start the server (with marketplace URL injected via env)
const astroBin = join(fixtureNodeModules, ".bin", "astro");
const server = spawn(astroBin, ["dev", "--port", String(PORT)], {
cwd: workDir,
env: {
...process.env,
const fixtureEnv = {
...process.env,
EMDASH_TEST_DB: `file:${dbPath}`,
EMDASH_MARKETPLACE_URL: marketplaceUrl,
EMDASH_REGISTRY_URL: marketplaceUrl,
EMDASH_REGISTRY_FIXTURE: registryFixturePath,
EMDASH_ENCRYPTION_KEY: "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
};
let server;
if (PRODUCTION_BUILD) {
Object.assign(fixtureEnv, {
// The build derives `image.remotePatterns` for EmDash media from the site URL.
EMDASH_SITE_URL: baseUrl,
});
console.log("[pw] Building fixture...");
const buildStart = Date.now();
await execAsync(astroBin, ["build"], { cwd: workDir, env: fixtureEnv, timeout: 300_000 });
console.log(`[pw] Fixture built in ${((Date.now() - buildStart) / 1000).toFixed(1)}s.`);
server = spawn(process.execPath, [join(workDir, "dist/server/entry.mjs")], {
cwd: workDir,
env: {
...fixtureEnv,
HOST: "localhost",
PORT: String(PORT),
},
stdio: "pipe",
});
} else {
server = spawn(astroBin, ["dev", "--port", String(PORT)], {
cwd: workDir,
// Keep Astro's agent-mode server in this process so teardown owns it.
ASTRO_DEV_BACKGROUND: "1",
EMDASH_TEST_DB: `file:${dbPath}`,
EMDASH_MARKETPLACE_URL: marketplaceUrl,
EMDASH_REGISTRY_URL: marketplaceUrl,
EMDASH_REGISTRY_FIXTURE: registryFixturePath,
EMDASH_ENCRYPTION_KEY: "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
},
stdio: "pipe",
});
env: { ...fixtureEnv, ASTRO_DEV_BACKGROUND: "1" },
stdio: "pipe",
});
}

let serverOutput = "";
const appendServerOutput = (data: Buffer) => {
Expand Down Expand Up @@ -411,25 +449,29 @@ export default async function globalSetup(): Promise<void> {
console.log("[pw] Seeding test data...");
const seed = await seedTestData(baseUrl, token);

// 5b. Warm up pages that use emdash/ui (triggers Astro compilation of all
// component virtual modules, avoiding race conditions in tests)
console.log("[pw] Warming up pages...");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const resp = await fetch(`${baseUrl}/posts/post-with-image`);
if (resp.ok) break;
// Retry on compilation errors — Astro may need multiple passes
await new Promise((r) => setTimeout(r, 1000));
} catch {
await new Promise((r) => setTimeout(r, 1000));
// A production build compiles everything ahead of time, so only the dev
// server needs warming up.
if (!PRODUCTION_BUILD) {
// 5b. Warm up pages that use emdash/ui (triggers Astro compilation of all
// component virtual modules, avoiding race conditions in tests)
console.log("[pw] Warming up pages...");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const resp = await fetch(`${baseUrl}/posts/post-with-image`);
if (resp.ok) break;
// Retry on compilation errors — Astro may need multiple passes
await new Promise((r) => setTimeout(r, 1000));
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
}
}

// 5c. Load the admin in a browser. Its client dependencies are only
// discovered when a browser requests them, and the optimizer then forces
// a full reload that would otherwise land inside the first admin test.
console.log("[pw] Warming up admin...");
await warmUpAdmin(baseUrl);
// 5c. Load the admin in a browser. Its client dependencies are only
// discovered when a browser requests them, and the optimizer then forces
// a full reload that would otherwise land inside the first admin test.
console.log("[pw] Warming up admin...");
await warmUpAdmin(baseUrl);
}

// 6. Write server info
const info = {
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/editor-image-panel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures";

const EXTERNAL_IMAGE_URL = "http://media.example.test/editor-panel.png";
const EXTERNAL_IMAGE_URL = "https://media.example.test/editor-panel.png";

test.describe("Editor image panel", () => {
test.beforeEach(async ({ admin }) => {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/api/dev-only.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
declare const __EMDASH_TEST_HOOKS__: boolean;

/**
* Gate for development-only endpoints and behavior (dev-bypass, dev-reset,
* captured dev emails, localhost registries).
*
* True under `astro dev`, and in builds made with `dangerouslyEnableTestHooks`.
* Both are build-time constants: in a normal production build this returns
* `false`, and no environment variable or request can change that.
*/
export function isDevOnlyEnabled(): boolean {
return (
import.meta.env.DEV || (typeof __EMDASH_TEST_HOOKS__ !== "undefined" && __EMDASH_TEST_HOOKS__)
);
}
3 changes: 2 additions & 1 deletion packages/core/src/api/handlers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import type { RegistryConfig, RegistryConfigInput } from "../../registry/types.j
import { resolveAndValidateExternalUrlTarget } from "../../security/ssrf.js";
import { EmDashStorageError } from "../../storage/types.js";
import type { Storage } from "../../storage/types.js";
import { isDevOnlyEnabled } from "../dev-only.js";
import type { ApiResult } from "../types.js";
import {
deleteBundleFromR2,
Expand Down Expand Up @@ -374,7 +375,7 @@ async function fetchArtifact(
},
resolveHostname: async (hostname) =>
(await resolveAndValidateExternalUrlTarget(`https://${hostname}`)).addresses,
allowHttpLocalhost: import.meta.env.DEV,
allowHttpLocalhost: isDevOnlyEnabled(),
headerTimeoutMs: ARTIFACT_FETCH_TIMEOUT_MS,
totalTimeoutMs: ARTIFACT_TOTAL_BUDGET_MS,
maxBytes: MAX_ARTIFACT_BYTES,
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,9 +632,17 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
astroCommand = command;
usesCloudflareAdapter = astroConfig.adapter?.name === "@astrojs/cloudflare";
normalizeRegistryConfig(registry, {
allowLocalhost: command === "dev" || command === "sync",
allowLocalhost:
command === "dev" ||
command === "sync" ||
resolvedConfig.dangerouslyEnableTestHooks === true,
});
printBanner(logger);
if (resolvedConfig.dangerouslyEnableTestHooks && command === "build") {
logger.warn(
"dangerouslyEnableTestHooks is on: anyone who can reach this build can sign in as an administrator. Use it only for end-to-end tests, and never deploy it.",
);
}
if (command === "dev") {
// In Node `astro dev`, Vite loads `.env` into `import.meta.env`
// but not `process.env`. The plugin-secret encryption key
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,20 @@ export interface EmDashConfig {
* `env:astro` requirement can be evaluated against the real host version.
*/
astroVersion?: string;

/**
* Turns on the development-only endpoints and behavior in a production
* build: dev-bypass login and setup, dev-reset, the console email provider
* and its captured-email endpoint, and plain-HTTP localhost registry URLs.
*
* For end-to-end tests of a built site only. Anyone who can reach a site
* built with this option can sign in as an administrator, so never deploy
* that build. The value is fixed at build time and cannot be switched on
* at runtime.
*
* @default false
*/
dangerouslyEnableTestHooks?: boolean;
}

const STORED_CONFIG_KEY = Symbol.for("emdash:stored-config");
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,9 @@ export function createViteConfig(
isDev && process.env["EMDASH_PSEUDO_LOCALE"] === "1",
),
__EMDASH_ADMIN_LOCALES__: JSON.stringify(adminLocales ?? null),
__EMDASH_TEST_HOOKS__: JSON.stringify(
options.resolvedConfig.dangerouslyEnableTestHooks === true,
),
},
resolve: {
dedupe: ["@emdash-cms/admin", "react", "react-dom"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { fetchVerifiedResource } from "@emdash-cms/registry-verification/fetch";
import type { APIRoute } from "astro";

import { requirePerm } from "#api/authorize.js";
import { isDevOnlyEnabled } from "#api/dev-only.js";
import { apiError } from "#api/error.js";

import { verifyRegistryArtifactChecksum } from "../../../../../../registry/artifact-checksum.js";
Expand Down Expand Up @@ -467,7 +468,7 @@ function proxyFetchOptions(signal: AbortSignal) {
}),
resolveHostname: async (hostname: string) =>
(await resolveAndValidateExternalUrlTarget(`https://${hostname}/`)).addresses,
allowHttpLocalhost: import.meta.env.DEV,
allowHttpLocalhost: isDevOnlyEnabled(),
maxBytes: MAX_IMAGE_BYTES,
maxRedirects: MAX_REDIRECTS,
totalTimeoutMs: FETCH_TIMEOUT_MS,
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/astro/routes/api/auth/dev-bypass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Development-only endpoint to bypass passkey authentication.
* Creates or uses a test admin user and establishes a session.
*
* ONLY available when import.meta.env.DEV is true.
* ONLY available when isDevOnlyEnabled() is true.
*
* Usage:
* - GET with redirect: /_emdash/api/auth/dev-bypass?redirect=/_emdash/admin
Expand All @@ -21,6 +21,7 @@ export const prerender = false;

import { ulid } from "ulidx";

import { isDevOnlyEnabled } from "#api/dev-only.js";
import { apiError, apiSuccess, handleError } from "#api/error.js";
import { escapeHtml } from "#api/escape.js";
import { isSafeRedirect } from "#api/redirect.js";
Expand All @@ -33,7 +34,7 @@ const ROLE_ADMIN = 50;

async function handleDevBypass(context: Parameters<APIRoute>[0]): Promise<Response> {
// CRITICAL: Only allow in development mode
if (!import.meta.env.DEV) {
if (!isDevOnlyEnabled()) {
return apiError("FORBIDDEN", "Dev bypass is only available in development mode", 403);
}

Expand Down
7 changes: 4 additions & 3 deletions packages/core/src/astro/routes/api/dev/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@
* Development-only endpoint to view and clear emails captured by
* the dev console email provider.
*
* ONLY available when import.meta.env.DEV is true.
* ONLY available when isDevOnlyEnabled() is true.
*
*/

import type { APIRoute } from "astro";

export const prerender = false;

import { isDevOnlyEnabled } from "#api/dev-only.js";
import { apiError, apiSuccess } from "#api/error.js";

import { clearDevEmails, getDevEmails } from "../../../../plugins/email-console.js";

export const GET: APIRoute = async () => {
if (!import.meta.env.DEV) {
if (!isDevOnlyEnabled()) {
return apiError("FORBIDDEN", "Dev emails endpoint is only available in development mode", 403);
}

Expand All @@ -27,7 +28,7 @@ export const GET: APIRoute = async () => {
};

export const DELETE: APIRoute = async () => {
if (!import.meta.env.DEV) {
if (!isDevOnlyEnabled()) {
return apiError("FORBIDDEN", "Dev emails endpoint is only available in development mode", 403);
}

Expand Down
Loading
Loading