From 8218039c64e94e9e0a27c3f98708b2af646bf4e1 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 3 Oct 2026 19:03:28 +0200 Subject: [PATCH] test(live): stop the live suites inheriting the production database Seven suites read .env with a bare `process.env[key] = value`, which overwrites whatever the shell already set. That made the DATABASE_URL from the production .env authoritative, so a single environment variable was enough to aim them at the live hotel database: RUN_CATALOG_AUDIT_LIVE=1 pnpm vitest run src/lib/services/catalog-audit-repair-live.test.ts Three of those suites then repair the catalog in place: catalog-audit-repair-live and catalog-repair-direct-live rewrite catalog_items and delete duplicate classnames, and clone-bulk-import-live bulk-imports every cloneable item. None of that is undoable, and nothing in their output said the target was production rather than a sandbox. Added src/test/live-env.ts with one shared loader, and pointed all seven suites at it: - Values already in the real environment win, so an explicit DATABASE_URL on the command line is always respected. - DATABASE_URL defaults to the sandbox on port 3307 rather than inheriting the production one from .env. - Anything that is not loopback is treated as production and redirected. - Reaching production requires ALLOW_PRODUCTION_LIVE_DB=1 and logs a warning saying the suite repairs the catalog. Tests in src/test/live-env.test.ts run the loader against a temporary .env so the real project file is never read, and cover the redirect, the shell override, non-loopback detection, the opt-in and quote stripping. A second block asserts each of the seven suites no longer contains an inline `process.env[...] =` assignment. Verified four of them fail against the old loader. This does not enable the suites; they stay gated behind their RUN_* flags. It only removes the possibility of them silently hitting production. Unit suite: 3330 passed, 12 skipped. Typecheck and lint clean. --- .../catalog-asset-repair-live.test.ts | 20 +-- src/lib/services/catalog-audit-live.test.ts | 19 +-- .../catalog-audit-repair-live.test.ts | 20 +-- .../catalog-audit-summary-live.test.ts | 20 +-- .../catalog-repair-direct-live.test.ts | 20 +-- .../services/clone-bulk-import-live.test.ts | 20 +-- .../services/clone-feasibility-live.test.ts | 20 +-- src/test/live-env.test.ts | 126 ++++++++++++++++++ src/test/live-env.ts | 75 +++++++++++ 9 files changed, 221 insertions(+), 119 deletions(-) create mode 100644 src/test/live-env.test.ts create mode 100644 src/test/live-env.ts diff --git a/src/lib/services/catalog-asset-repair-live.test.ts b/src/lib/services/catalog-asset-repair-live.test.ts index 7217a601..728571c6 100644 --- a/src/lib/services/catalog-asset-repair-live.test.ts +++ b/src/lib/services/catalog-asset-repair-live.test.ts @@ -1,9 +1,9 @@ // @ts-nocheck // Runs repairMissingIcons + repairMissingNitros directly (no source comparison // phase, which is the slow part of runCatalogAudit). Logs progress to a file. -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { appendFileSync } from "node:fs"; import { beforeAll, describe, expect, it } from "vitest"; +import { loadEnvForLiveTests } from "@/test/live-env"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const LOG = "/tmp/catalog-asset-repair.log"; @@ -16,21 +16,7 @@ describe.skipIf(!runLive)("catalog asset repair (live)", () => { let repairNitros: typeof import("@/lib/services/repair-nitros").repairMissingNitros; beforeAll(async () => { - const envFile = resolve(process.cwd(), ".env"); - if (existsSync(envFile)) { - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[m[1]] = value; - } - } + loadEnvForLiveTests(); process.env.SKIP_ENV_VALIDATION = "1"; [repairIcons, repairNitros] = await Promise.all([ diff --git a/src/lib/services/catalog-audit-live.test.ts b/src/lib/services/catalog-audit-live.test.ts index 0b03c063..055d654c 100644 --- a/src/lib/services/catalog-audit-live.test.ts +++ b/src/lib/services/catalog-audit-live.test.ts @@ -12,11 +12,10 @@ // Usage: // RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \ // src/lib/services/catalog-audit-live.test.ts -import { existsSync, readFileSync } from "node:fs"; import { readdir } from "node:fs/promises"; -import { resolve } from "node:path"; import { sql } from "drizzle-orm"; import { beforeAll, describe, expect, it } from "vitest"; +import { loadEnvForLiveTests } from "@/test/live-env"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const KNOWN_EVENT_TYPES = new Set([ @@ -41,21 +40,7 @@ describe.skipIf(!runLive)("catalog audit live (read-only)", () => { beforeAll(async () => { // vitest's test.env injects a throwaway DATABASE_URL (root:root@:3306). // Replace it with the real .env value so the audit targets the hotel DB. - const envFile = resolve(process.cwd(), ".env"); - if (existsSync(envFile)) { - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[m[1]] = value; - } - } + loadEnvForLiveTests(); const [dbMod, auditMod, typesMod] = await Promise.all([ import("@/lib/db"), diff --git a/src/lib/services/catalog-audit-repair-live.test.ts b/src/lib/services/catalog-audit-repair-live.test.ts index 58a8e844..1ab8df2c 100644 --- a/src/lib/services/catalog-audit-repair-live.test.ts +++ b/src/lib/services/catalog-audit-repair-live.test.ts @@ -9,9 +9,9 @@ // Run with the REAL DATABASE_URL loaded from .env: // RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \ // src/lib/services/catalog-audit-repair-live.test.ts -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { appendFileSync } from "node:fs"; import { beforeAll, describe, expect, it } from "vitest"; +import { loadEnvForLiveTests } from "@/test/live-env"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const LOG = "/tmp/catalog-audit-repair.log"; @@ -27,21 +27,7 @@ describe.skipIf(!runLive)("catalog audit live repair", () => { let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit; beforeAll(async () => { - const envFile = resolve(process.cwd(), ".env"); - if (existsSync(envFile)) { - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[m[1]] = value; - } - } + loadEnvForLiveTests(); const auditMod = await import("@/lib/services/catalog-audit"); runCatalogAudit = auditMod.runCatalogAudit; diff --git a/src/lib/services/catalog-audit-summary-live.test.ts b/src/lib/services/catalog-audit-summary-live.test.ts index 501aec02..b323e87b 100644 --- a/src/lib/services/catalog-audit-summary-live.test.ts +++ b/src/lib/services/catalog-audit-summary-live.test.ts @@ -1,8 +1,8 @@ // @ts-nocheck // Read-only audit run that writes the full summary to a log file. -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { appendFileSync } from "node:fs"; import { beforeAll, describe, expect, it } from "vitest"; +import { loadEnvForLiveTests } from "@/test/live-env"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const LOG = "/tmp/catalog-audit-summary.log"; @@ -14,21 +14,7 @@ describe.skipIf(!runLive)("catalog audit read-only summary", () => { let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit; beforeAll(async () => { - const envFile = resolve(process.cwd(), ".env"); - if (existsSync(envFile)) { - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[m[1]] = value; - } - } + loadEnvForLiveTests(); const auditMod = await import("@/lib/services/catalog-audit"); runCatalogAudit = auditMod.runCatalogAudit; diff --git a/src/lib/services/catalog-repair-direct-live.test.ts b/src/lib/services/catalog-repair-direct-live.test.ts index c0e11069..428013d5 100644 --- a/src/lib/services/catalog-repair-direct-live.test.ts +++ b/src/lib/services/catalog-repair-direct-live.test.ts @@ -1,8 +1,7 @@ // @ts-nocheck // Direct repair execution against the live DB. Skips the slow clone-source // comparison entirely and just runs the repair functions. -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { appendFileSync } from "node:fs"; const LOG = "/tmp/catalog-repair.log"; const log = (msg: string) => { @@ -12,6 +11,7 @@ const log = (msg: string) => { import { sql } from "drizzle-orm"; import { beforeAll, describe, expect, it } from "vitest"; +import { loadEnvForLiveTests } from "@/test/live-env"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; @@ -20,21 +20,7 @@ describe.skipIf(!runLive)("catalog repair direct (live)", () => { let repair: typeof import("@/lib/services/catalog-repair"); beforeAll(async () => { - const envFile = resolve(process.cwd(), ".env"); - if (existsSync(envFile)) { - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[m[1]] = value; - } - } + loadEnvForLiveTests(); const [dbMod, repairMod] = await Promise.all([ import("@/lib/db"), diff --git a/src/lib/services/clone-bulk-import-live.test.ts b/src/lib/services/clone-bulk-import-live.test.ts index 7b672605..9a2c1417 100644 --- a/src/lib/services/clone-bulk-import-live.test.ts +++ b/src/lib/services/clone-bulk-import-live.test.ts @@ -2,9 +2,9 @@ // Bulk-import live run: imports every cloneable item from the sources that // reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to // shrink missingFromSources before disabling dead sources. -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { appendFileSync } from "node:fs"; import { beforeAll, describe, expect, it } from "vitest"; +import { loadEnvForLiveTests } from "@/test/live-env"; const runLive = process.env.RUN_CLONE_BULK_LIVE === "1"; const LOG = "/tmp/clone-bulk.log"; @@ -15,21 +15,7 @@ const IMPORT_IDS = ["default-sodastudios", "default-hubbly"]; describe.skipIf(!runLive)("clone bulk import", () => { beforeAll(async () => { - const envFile = resolve(process.cwd(), ".env"); - if (existsSync(envFile)) { - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[m[1]] = value; - } - } + loadEnvForLiveTests(); }); it("imports clonable items from delivering sources", async () => { diff --git a/src/lib/services/clone-feasibility-live.test.ts b/src/lib/services/clone-feasibility-live.test.ts index 4f0df17e..57e8f573 100644 --- a/src/lib/services/clone-feasibility-live.test.ts +++ b/src/lib/services/clone-feasibility-live.test.ts @@ -2,9 +2,9 @@ // Feasibility probe: splits the audit's missing-from-sources list per clone // source, marks which sources can deliver .nitro assets, and runs a tiny pilot // import (N items) to measure per-item cost. Writes a report to /tmp. -import { appendFileSync, existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { appendFileSync } from "node:fs"; import { beforeAll, describe, expect, it } from "vitest"; +import { loadEnvForLiveTests } from "@/test/live-env"; const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1"; const LOG = "/tmp/clone-feasibility.log"; @@ -12,21 +12,7 @@ const log = (msg: string) => appendFileSync(LOG, `${msg}\n`); describe.skipIf(!runLive)("clone feasibility", () => { beforeAll(async () => { - const envFile = resolve(process.cwd(), ".env"); - if (existsSync(envFile)) { - for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { - const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - process.env[m[1]] = value; - } - } + loadEnvForLiveTests(); }); it("reports per-source clonable counts + pilot", async () => { diff --git a/src/test/live-env.test.ts b/src/test/live-env.test.ts new file mode 100644 index 00000000..956dbe90 --- /dev/null +++ b/src/test/live-env.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { loadEnvForLiveTests } from "./live-env"; + +const ORIGINAL_ENV = { ...process.env }; +const SANDBOX = "mysql://root@127.0.0.1:3307/habbo_sandbox?charset=utf8mb4"; + +/** + * Run the helper against a temporary .env so the real project file is never + * read and the ambient environment cannot leak in. + */ +function runWithDotEnv(dotEnv: string | null, shell: Record) { + const dir = mkdtempSync(join(tmpdir(), "live-env-test-")); + const previousCwd = process.cwd(); + try { + if (dotEnv !== null) writeFileSync(join(dir, ".env"), dotEnv); + // process.chdir keeps process.cwd() inside the helper pointing at dir. + process.chdir(dir); + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, shell); + loadEnvForLiveTests(); + return { ...process.env }; + } finally { + process.chdir(previousCwd); + rmSync(dir, { recursive: true, force: true }); + } +} + +afterEach(() => { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, ORIGINAL_ENV); +}); + +describe("loadEnvForLiveTests", () => { + // The suites these protect rewrite the catalog, delete duplicate + // classnames and bulk-import thousands of rows. A single env var used to be + // enough to aim them at the live hotel database. + it("never inherits a production database from .env", () => { + const env = runWithDotEnv( + [ + "DATABASE_URL='mysql://cms:secret@db.example.nl:3306/habbo'", + "HOTEL_NAME='Test Hotel'", + ].join("\n"), + {}, + ); + expect(env.DATABASE_URL).toBe(SANDBOX); + // Non-database settings still load, so the suites stay usable. + expect(env.HOTEL_NAME).toBe("Test Hotel"); + }); + + it("keeps an explicit shell override", () => { + const env = runWithDotEnv( + "DATABASE_URL='mysql://cms:secret@db.example.nl:3306/habbo'", + { + DATABASE_URL: "mysql://root@127.0.0.1:3399/other?charset=utf8mb4", + }, + ); + expect(env.DATABASE_URL).toBe( + "mysql://root@127.0.0.1:3399/other?charset=utf8mb4", + ); + }); + + it("treats any non-loopback database as production", () => { + const env = runWithDotEnv(null, { + DATABASE_URL: "mysql://user:pw@10.0.0.5:3306/habbo", + }); + expect(env.DATABASE_URL).toBe(SANDBOX); + }); + + it("allows production only behind an explicit opt-in", () => { + const production = "mysql://cms:secret@db.example.nl:3306/habbo"; + const env = runWithDotEnv(null, { + DATABASE_URL: production, + ALLOW_PRODUCTION_LIVE_DB: "1", + }); + expect(env.DATABASE_URL).toBe(production); + }); + + it("stays on the sandbox when the opt-in is set but the URL is safe", () => { + const env = runWithDotEnv(null, { + DATABASE_URL: "mysql://root@127.0.0.1:3307/habbo_sandbox?charset=utf8mb4", + ALLOW_PRODUCTION_LIVE_DB: "1", + }); + expect(env.DATABASE_URL).toBe(SANDBOX); + }); + + it("supplies a sandbox when .env has no database at all", () => { + const env = runWithDotEnv("HOTEL_NAME='Test Hotel'", {}); + expect(env.DATABASE_URL).toBe(SANDBOX); + }); + + it("strips quotes the way the previous inline loader did", () => { + const env = runWithDotEnv( + ['AUTH_SECRET="quoted-secret"', "OTHER='single'"].join("\n"), + {}, + ); + expect(env.AUTH_SECRET).toBe("quoted-secret"); + expect(env.OTHER).toBe("single"); + }); +}); + +describe("live suites no longer inline the .env loader", () => { + const suites = [ + "catalog-audit-repair-live", + "catalog-audit-live", + "clone-bulk-import-live", + "clone-feasibility-live", + "catalog-repair-direct-live", + "catalog-asset-repair-live", + "catalog-audit-summary-live", + ]; + + it.each(suites)("%s delegates to the shared loader", (name) => { + const source = readFileSync( + resolve(process.cwd(), "src/lib/services", `${name}.test.ts`), + "utf8", + ); + // A bare assignment would overwrite an operator's explicit DATABASE_URL, + // which is how production used to win. + expect(source).not.toMatch(/process\.env\[m\[1\]\]\s*=/); + expect(source).toContain("loadEnvForLiveTests()"); + }); +}); diff --git a/src/test/live-env.ts b/src/test/live-env.ts new file mode 100644 index 00000000..57c4fdf1 --- /dev/null +++ b/src/test/live-env.ts @@ -0,0 +1,75 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Sandbox database for the live suites. Production runs on port 3306; the + * throwaway MariaDB used by the rehearsal suites listens on 3307. + */ +export const SANDBOX_DATABASE_URL = + "mysql://root@127.0.0.1:3307/habbo_sandbox?charset=utf8mb4"; + +/** + * Load .env for the live suites without ever pointing them at production. + * + * Every live suite used to read .env with a bare + * `process.env[key] = value`, which overwrites whatever the shell already set. + * That made the DATABASE_URL from the production .env authoritative: setting + * RUN_CATALOG_AUDIT_LIVE=1 pointed three suites — catalog-audit-repair-live, + * catalog-repair-direct-live and clone-bulk-import-live — at the live hotel + * database, where they rewrite the catalog, delete duplicate classnames and + * bulk-import thousands of rows. The failure mode was silent: the gate is a + * single environment variable, and nothing in the suite said the target was + * production. + * + * Rules now: + * 1. Values already present in the real environment win, so an explicit + * DATABASE_URL on the command line is always respected. + * 2. DATABASE_URL defaults to the sandbox, never to production. + * 3. Targeting production requires ALLOW_PRODUCTION_LIVE_DB=1 and says so + * loudly, because it is destructive and not undoable. + */ +export function loadEnvForLiveTests(): void { + const allowProduction = process.env.ALLOW_PRODUCTION_LIVE_DB === "1"; + const envFile = resolve(process.cwd(), ".env"); + + if (existsSync(envFile)) { + for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!match) continue; + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + // Already-set values win: the shell is a deliberate override. + if (process.env[match[1]] === undefined) { + process.env[match[1]] = value; + } + } + } + + const databaseUrl = process.env.DATABASE_URL ?? ""; + const targetsProduction = + databaseUrl.includes(":3306") || + (databaseUrl.includes("@") && !databaseUrl.includes("127.0.0.1")); + + if (allowProduction) { + if (targetsProduction) { + console.warn( + "[live-test] ALLOW_PRODUCTION_LIVE_DB=1 and DATABASE_URL points at " + + "a remote database. This suite repairs the catalog in place.", + ); + } + return; + } + + if (targetsProduction || !databaseUrl) { + process.env.DATABASE_URL = SANDBOX_DATABASE_URL; + console.warn( + `[live-test] redirected DATABASE_URL to the sandbox at ${SANDBOX_DATABASE_URL}. ` + + "Set ALLOW_PRODUCTION_LIVE_DB=1 to override.", + ); + } +}