diff --git a/.changeset/dangerously-enable-test-hooks.md b/.changeset/dangerously-enable-test-hooks.md new file mode 100644 index 0000000000..afdcd22b2c --- /dev/null +++ b/.changeset/dangerously-enable-test-hooks.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 429affbddf..40a7173473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index 8b2891a360..deb3423b55 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/e2e/fixture/astro.config.mjs b/e2e/fixture/astro.config.mjs index 047ae3d8b6..1b2d20ffb0 100644 --- a/e2e/fixture/astro.config.mjs +++ b/e2e/fixture/astro.config.mjs @@ -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: { diff --git a/e2e/fixture/package.json b/e2e/fixture/package.json index 66e9718242..72567bfcf0 100644 --- a/e2e/fixture/package.json +++ b/e2e/fixture/package.json @@ -12,6 +12,7 @@ "astro": "catalog:", "emdash": "workspace:*", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "sharp": "^0.35.4" } } diff --git a/e2e/fixture/src/middleware.ts b/e2e/fixture/src/middleware.ts new file mode 100644 index 0000000000..91f72f1e3d --- /dev/null +++ b/e2e/fixture/src/middleware.ts @@ -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()); diff --git a/e2e/fixture/src/outer-middleware.ts b/e2e/fixture/src/outer-middleware.ts index 327310a38c..f6f115b9ff 100644 --- a/e2e/fixture/src/outer-middleware.ts +++ b/e2e/fixture/src/outer-middleware.ts @@ -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 { diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 6057d6c479..98bc3d4c90 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -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"; @@ -46,6 +46,11 @@ const TARGETS: Record = { }; 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; @@ -336,7 +341,19 @@ export default async function globalSetup(): Promise { // 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)); @@ -352,22 +369,43 @@ export default async function globalSetup(): Promise { 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) => { @@ -411,25 +449,29 @@ export default async function globalSetup(): Promise { 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 = { diff --git a/e2e/tests/editor-image-panel.spec.ts b/e2e/tests/editor-image-panel.spec.ts index 5f8a509500..a4d7b35394 100644 --- a/e2e/tests/editor-image-panel.spec.ts +++ b/e2e/tests/editor-image-panel.spec.ts @@ -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 }) => { diff --git a/packages/core/src/api/dev-only.ts b/packages/core/src/api/dev-only.ts new file mode 100644 index 0000000000..e4fd45ce92 --- /dev/null +++ b/packages/core/src/api/dev-only.ts @@ -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__) + ); +} diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index 440d746888..6df2b4f5a9 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -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, @@ -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, diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 6c0a865628..45eb3674d2 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -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 diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index be82cf9218..8d4773d307 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -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"); diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 81d8490cce..9590b5c8ac 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -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"], diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts index bb118de6d3..23077f6d5c 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/artifact.ts @@ -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"; @@ -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, diff --git a/packages/core/src/astro/routes/api/auth/dev-bypass.ts b/packages/core/src/astro/routes/api/auth/dev-bypass.ts index 9f5f48497d..29ecd21358 100644 --- a/packages/core/src/astro/routes/api/auth/dev-bypass.ts +++ b/packages/core/src/astro/routes/api/auth/dev-bypass.ts @@ -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 @@ -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"; @@ -33,7 +34,7 @@ const ROLE_ADMIN = 50; async function handleDevBypass(context: Parameters[0]): Promise { // 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); } diff --git a/packages/core/src/astro/routes/api/dev/emails.ts b/packages/core/src/astro/routes/api/dev/emails.ts index e277b5577e..9fede2a051 100644 --- a/packages/core/src/astro/routes/api/dev/emails.ts +++ b/packages/core/src/astro/routes/api/dev/emails.ts @@ -5,7 +5,7 @@ * 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. * */ @@ -13,12 +13,13 @@ 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); } @@ -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); } diff --git a/packages/core/src/astro/routes/api/setup/dev-bypass.ts b/packages/core/src/astro/routes/api/setup/dev-bypass.ts index 2cc26ebc59..209b9d3b7f 100644 --- a/packages/core/src/astro/routes/api/setup/dev-bypass.ts +++ b/packages/core/src/astro/routes/api/setup/dev-bypass.ts @@ -6,7 +6,7 @@ * Creates a dev admin user and marks setup complete after runtime initialization. * Newly applied sample content has its Media Usage index prepared before redirecting. * - * ONLY available when import.meta.env.DEV is true. + * ONLY available when isDevOnlyEnabled() is true. * * Usage: * - GET with redirect: /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin @@ -24,6 +24,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 { deleteApiTokensByName, handleApiTokenCreate } from "#api/handlers/api-tokens.js"; @@ -44,7 +45,7 @@ const DEV_SITE_TITLE = "EmDash Dev Site"; async function handleDevBypass(context: Parameters[0]): Promise { // 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); } diff --git a/packages/core/src/astro/routes/api/setup/dev-reset.ts b/packages/core/src/astro/routes/api/setup/dev-reset.ts index 04d0a484b3..af2b094029 100644 --- a/packages/core/src/astro/routes/api/setup/dev-reset.ts +++ b/packages/core/src/astro/routes/api/setup/dev-reset.ts @@ -5,18 +5,19 @@ * Clears the setup_complete flag and deletes all users, * returning the site to the pre-setup state. * - * 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, handleError } from "#api/error.js"; import { OptionsRepository } from "#db/repositories/options.js"; export const POST: APIRoute = async ({ locals }) => { - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { return apiError("FORBIDDEN", "Dev reset is only available in development mode", 403); } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index cf17707a38..9fc4235a77 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -22,6 +22,7 @@ import { Kysely, type Dialect } from "kysely"; import virtualConfig from "virtual:emdash/config"; import { z } from "zod"; +import { isDevOnlyEnabled } from "./api/dev-only.js"; import { ErrorCode } from "./api/errors.js"; import { buildManifestCollections } from "./api/handlers/manifest.js"; import { @@ -1923,8 +1924,8 @@ export class EmDashRuntime { // In dev mode, register a built-in console email provider. // It participates in exclusive hook resolution like any other plugin — // auto-selected when it's the sole provider, overridden when a real one is configured. - // Gated by import.meta.env.DEV to prevent silent email loss in production. - if (import.meta.env.DEV) { + // Gated by isDevOnlyEnabled() to prevent silent email loss in production. + if (isDevOnlyEnabled()) { try { const devConsolePlugin = definePlugin({ id: DEV_CONSOLE_EMAIL_PLUGIN_ID, diff --git a/packages/core/src/registry/artifact-fetch.ts b/packages/core/src/registry/artifact-fetch.ts index f2f7a692ff..990e05c72c 100644 --- a/packages/core/src/registry/artifact-fetch.ts +++ b/packages/core/src/registry/artifact-fetch.ts @@ -1,6 +1,7 @@ import type { ClientRequest, IncomingMessage } from "node:http"; import type { RequestOptions } from "node:https"; +import { isDevOnlyEnabled } from "../api/dev-only.js"; import { resolveAndValidateExternalUrlTarget, SsrfError } from "../security/ssrf.js"; const TRAILING_DOT = /\.+$/; @@ -75,7 +76,7 @@ async function resolveSafeArtifactTarget(urlString: string): Promise<{ const hostname = stripIpv6Brackets(rawHostname); const localhost = isLocalhostHostname(hostname); - if (!import.meta.env.DEV) { + if (!isDevOnlyEnabled()) { if (url.protocol === "http:") { throw new Error("Artifact URL must use https"); } diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index 86f7f18a80..a8e0a2e1e3 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -9,6 +9,7 @@ import { isDid } from "@atcute/lexicons/syntax"; +import { isDevOnlyEnabled } from "../api/dev-only.js"; import type { RegistryConfig, RegistryConfigInput, RegistryConfigOption } from "./types.js"; export const DEFAULT_REGISTRY_AGGREGATOR_URL = "https://registry.emdashcms.com"; @@ -280,7 +281,7 @@ export function validateAggregatorUrl( hostname.startsWith("::ffff:127.") || hostname.startsWith("::ffff:7f00:"); - const allowLocalhost = options.allowLocalhost ?? import.meta.env.DEV; + const allowLocalhost = options.allowLocalhost ?? isDevOnlyEnabled(); if (!allowLocalhost) { if (parsed.protocol === "http:") { throw new RegistryConfigurationError( diff --git a/packages/core/tests/unit/astro/integration/test-hooks-option.test.ts b/packages/core/tests/unit/astro/integration/test-hooks-option.test.ts new file mode 100644 index 0000000000..7ac5801069 --- /dev/null +++ b/packages/core/tests/unit/astro/integration/test-hooks-option.test.ts @@ -0,0 +1,47 @@ +import type { AstroIntegration } from "astro"; +import { describe, expect, it, vi } from "vitest"; + +import type { EmDashConfig } from "../../../../src/astro/integration/index.js"; +import { emdash } from "../../../../src/astro/integration/index.js"; + +function configSetup(integration: AstroIntegration) { + const handler = integration.hooks["astro:config:setup"]; + if (typeof handler !== "function") throw new Error("Missing astro:config:setup hook"); + return handler; +} + +async function build(config: EmDashConfig) { + const root = new URL("file:///tmp/emdash-test-hooks/"); + const logger = { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }; + await configSetup(emdash(config))({ + command: "build", + config: { + root, + srcDir: new URL("src/", root), + security: {}, + trailingSlash: "ignore", + integrations: [{ name: "@astrojs/react", hooks: {} }], + }, + logger, + injectRoute: vi.fn(), + addMiddleware: vi.fn(), + updateConfig: vi.fn(), + } as never); + return logger.warn.mock.calls.map(([message]) => String(message)); +} + +describe("dangerouslyEnableTestHooks", () => { + it("rejects a localhost registry in a production build without it", async () => { + await expect(build({ registry: "http://127.0.0.1:4445" })).rejects.toThrow( + "must use HTTPS outside development", + ); + }); + + it("accepts a localhost registry in a production build and warns", async () => { + const warnings = await build({ + registry: "http://127.0.0.1:4445", + dangerouslyEnableTestHooks: true, + }); + expect(warnings).toContainEqual(expect.stringContaining("never deploy it")); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a66e1780e..bc4200add9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1145,6 +1145,9 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.4(react@19.2.4) + sharp: + specifier: ^0.35.4 + version: 0.35.4(@types/node@26.1.1) e2e/fixture-cloudflare: dependencies: @@ -22366,7 +22369,6 @@ snapshots: '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 '@types/node': 26.1.1 - optional: true shebang-command@2.0.0: dependencies: