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.
This commit is contained in:
openhands committed 2026-10-03 19:03:28 +02:00
1 parent f705c67fc7
commit 8218039c64
9 files changed
+221 -119

No files matched your search

@@ -1,9 +1,9 @@
// @ts-nocheck // @ts-nocheck
// Runs repairMissingIcons + repairMissingNitros directly (no source comparison // Runs repairMissingIcons + repairMissingNitros directly (no source comparison
// phase, which is the slow part of runCatalogAudit). Logs progress to a file. // phase, which is the slow part of runCatalogAudit). Logs progress to a file.
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadEnvForLiveTests } from "@/test/live-env";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
const LOG = "/tmp/catalog-asset-repair.log"; 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; let repairNitros: typeof import("@/lib/services/repair-nitros").repairMissingNitros;
beforeAll(async () => { beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env"); loadEnvForLiveTests();
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;
}
}
process.env.SKIP_ENV_VALIDATION = "1"; process.env.SKIP_ENV_VALIDATION = "1";
[repairIcons, repairNitros] = await Promise.all([ [repairIcons, repairNitros] = await Promise.all([
+2 -17
View File
@@ -12,11 +12,10 @@
// Usage: // Usage:
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \ // RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
// src/lib/services/catalog-audit-live.test.ts // src/lib/services/catalog-audit-live.test.ts
import { existsSync, readFileSync } from "node:fs";
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { resolve } from "node:path";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadEnvForLiveTests } from "@/test/live-env";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
const KNOWN_EVENT_TYPES = new Set([ const KNOWN_EVENT_TYPES = new Set([
@@ -41,21 +40,7 @@ describe.skipIf(!runLive)("catalog audit live (read-only)", () => {
beforeAll(async () => { beforeAll(async () => {
// vitest's test.env injects a throwaway DATABASE_URL (root:root@:3306). // 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. // Replace it with the real .env value so the audit targets the hotel DB.
const envFile = resolve(process.cwd(), ".env"); loadEnvForLiveTests();
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;
}
}
const [dbMod, auditMod, typesMod] = await Promise.all([ const [dbMod, auditMod, typesMod] = await Promise.all([
import("@/lib/db"), import("@/lib/db"),
@@ -9,9 +9,9 @@
// Run with the REAL DATABASE_URL loaded from .env: // Run with the REAL DATABASE_URL loaded from .env:
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \ // RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
// src/lib/services/catalog-audit-repair-live.test.ts // src/lib/services/catalog-audit-repair-live.test.ts
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadEnvForLiveTests } from "@/test/live-env";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
const LOG = "/tmp/catalog-audit-repair.log"; 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; let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
beforeAll(async () => { beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env"); loadEnvForLiveTests();
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;
}
}
const auditMod = await import("@/lib/services/catalog-audit"); const auditMod = await import("@/lib/services/catalog-audit");
runCatalogAudit = auditMod.runCatalogAudit; runCatalogAudit = auditMod.runCatalogAudit;
@@ -1,8 +1,8 @@
// @ts-nocheck // @ts-nocheck
// Read-only audit run that writes the full summary to a log file. // Read-only audit run that writes the full summary to a log file.
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadEnvForLiveTests } from "@/test/live-env";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
const LOG = "/tmp/catalog-audit-summary.log"; 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; let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
beforeAll(async () => { beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env"); loadEnvForLiveTests();
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;
}
}
const auditMod = await import("@/lib/services/catalog-audit"); const auditMod = await import("@/lib/services/catalog-audit");
runCatalogAudit = auditMod.runCatalogAudit; runCatalogAudit = auditMod.runCatalogAudit;
@@ -1,8 +1,7 @@
// @ts-nocheck // @ts-nocheck
// Direct repair execution against the live DB. Skips the slow clone-source // Direct repair execution against the live DB. Skips the slow clone-source
// comparison entirely and just runs the repair functions. // comparison entirely and just runs the repair functions.
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
const LOG = "/tmp/catalog-repair.log"; const LOG = "/tmp/catalog-repair.log";
const log = (msg: string) => { const log = (msg: string) => {
@@ -12,6 +11,7 @@ const log = (msg: string) => {
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadEnvForLiveTests } from "@/test/live-env";
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; 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"); let repair: typeof import("@/lib/services/catalog-repair");
beforeAll(async () => { beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env"); loadEnvForLiveTests();
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;
}
}
const [dbMod, repairMod] = await Promise.all([ const [dbMod, repairMod] = await Promise.all([
import("@/lib/db"), import("@/lib/db"),
@@ -2,9 +2,9 @@
// Bulk-import live run: imports every cloneable item from the sources that // Bulk-import live run: imports every cloneable item from the sources that
// reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to // reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to
// shrink missingFromSources before disabling dead sources. // shrink missingFromSources before disabling dead sources.
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadEnvForLiveTests } from "@/test/live-env";
const runLive = process.env.RUN_CLONE_BULK_LIVE === "1"; const runLive = process.env.RUN_CLONE_BULK_LIVE === "1";
const LOG = "/tmp/clone-bulk.log"; const LOG = "/tmp/clone-bulk.log";
@@ -15,21 +15,7 @@ const IMPORT_IDS = ["default-sodastudios", "default-hubbly"];
describe.skipIf(!runLive)("clone bulk import", () => { describe.skipIf(!runLive)("clone bulk import", () => {
beforeAll(async () => { beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env"); loadEnvForLiveTests();
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;
}
}
}); });
it("imports clonable items from delivering sources", async () => { it("imports clonable items from delivering sources", async () => {
@@ -2,9 +2,9 @@
// Feasibility probe: splits the audit's missing-from-sources list per clone // 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 // 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 (N items) to measure per-item cost. Writes a report to /tmp.
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { loadEnvForLiveTests } from "@/test/live-env";
const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1"; const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1";
const LOG = "/tmp/clone-feasibility.log"; const LOG = "/tmp/clone-feasibility.log";
@@ -12,21 +12,7 @@ const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
describe.skipIf(!runLive)("clone feasibility", () => { describe.skipIf(!runLive)("clone feasibility", () => {
beforeAll(async () => { beforeAll(async () => {
const envFile = resolve(process.cwd(), ".env"); loadEnvForLiveTests();
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;
}
}
}); });
it("reports per-source clonable counts + pilot", async () => { it("reports per-source clonable counts + pilot", async () => {
File diff suppressed because it is too large. Load diff
+75
View File
@@ -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://[email protected]: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.",
);
}
}