Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 31s
CI / tests-unit (push) Successful in 1m57s
CI / tests-integration (push) Successful in 2m1s
CI / tests-ui (push) Successful in 2m51s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m42s
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.
127 lines
4.0 KiB
TypeScript
127 lines
4.0 KiB
TypeScript
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://[email protected]: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<string, string>) {
|
|
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:[email protected]: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:[email protected]:3306/habbo'",
|
|
{
|
|
DATABASE_URL: "mysql://[email protected]:3399/other?charset=utf8mb4",
|
|
},
|
|
);
|
|
expect(env.DATABASE_URL).toBe(
|
|
"mysql://[email protected]:3399/other?charset=utf8mb4",
|
|
);
|
|
});
|
|
|
|
it("treats any non-loopback database as production", () => {
|
|
const env = runWithDotEnv(null, {
|
|
DATABASE_URL: "mysql://user:[email protected]:3306/habbo",
|
|
});
|
|
expect(env.DATABASE_URL).toBe(SANDBOX);
|
|
});
|
|
|
|
it("allows production only behind an explicit opt-in", () => {
|
|
const production = "mysql://cms:[email protected]: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://[email protected]: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()");
|
|
});
|
|
});
|