fix: isolate test-mode database access

Prevent automated tests from inheriting production PostgreSQL URLs and route global test-mode startups to a dedicated external or embedded test database.
This commit is contained in:
gsxdsm
2026-07-19 00:10:02 -07:00
parent b4c1a1ac77
commit ecffdbb14b
9 changed files with 209 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Isolate automated tests and global test-mode runs from the normal Fusion database.
category: fix
dev: Adds dedicated FUSION_TEST_DATABASE_URL routing with a separate embedded test cluster fallback.

View File

@@ -1746,6 +1746,10 @@ Activation UX/settings affordances are handled separately in FN-5204.
`testMode?: boolean` exists at both global and project scopes. Project `testMode: true` takes precedence and forces planning, executor, reviewer/validator, mission validation, merger, and heartbeat to `mock/scripted` regardless of per-task or per-lane overrides. The dashboard surfaces this with the Settings Modal "Enable test mode" toggle and the shell banner: "Test mode — no real AI calls".
When Fusion starts with global test mode enabled, it also isolates persistence from the normal database. The normal `DATABASE_URL` is ignored: Fusion uses the dedicated external URL in `FUSION_TEST_DATABASE_URL` (and optional `FUSION_TEST_DATABASE_MIGRATION_URL`) or, when no test URL is configured, the separate embedded cluster at `~/.fusion/embedded-postgres/test`. Database selection happens at process startup, so restart Fusion after changing global test mode. `FUSION_TEST_MODE=1` provides the same startup behavior for scripted runs.
Automated test commands likewise remove inherited `DATABASE_URL` and `DATABASE_MIGRATION_URL` values before starting test workers. PostgreSQL integration suites continue to use their isolated `FUSION_PG_TEST_*` databases.
## Per-task token budget precedence
1. `task.tokenBudgetOverride`

View File

@@ -74,6 +74,13 @@ function installWarningFilter(): void {
installWarningFilter();
// Never let a DATABASE_URL exported for the operator's normal Fusion runtime
// leak into a Vitest worker. Tests that exercise external PostgreSQL construct
// an isolated database through FUSION_PG_TEST_* and set DATABASE_URL explicitly
// inside the test after this setup file has run.
delete process.env.DATABASE_URL;
delete process.env.DATABASE_MIGRATION_URL;
const TEST_HOME_PREFIX = "fn-test-home-";
const WORKER_ROOT_OWNER_FILE = ".fusion-test-worker-root-owner";
const FUSION_TEST_RUN_TOKEN_ENV = "FUSION_TEST_RUN_TOKEN";

View File

@@ -20,7 +20,7 @@
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
@@ -31,6 +31,7 @@ import {
isEmbeddedPgOptedOut,
EMBEDDED_PG_ENV,
NO_EMBEDDED_PG_ENV,
resolveStartupDatabaseOptions,
} from "../../postgres/startup-factory.js";
import { resolveBackend } from "../../postgres/backend-resolver.js";
@@ -129,6 +130,65 @@ describe("startup-factory: shouldUsePostgresBackend", () => {
});
});
describe("startup-factory: test database selection", () => {
let globalSettingsDir: string;
beforeEach(async () => {
globalSettingsDir = await mkdtemp(join(tmpdir(), "startup-test-database-"));
});
afterEach(async () => {
await rm(globalSettingsDir, { recursive: true, force: true });
});
it("removes an inherited production target before Vitest modules execute", () => {
expect(process.env.DATABASE_URL).toBeUndefined();
expect(process.env.DATABASE_MIGRATION_URL).toBeUndefined();
});
it("ignores the production DATABASE_URL when persisted global test mode is enabled", async () => {
await writeFile(join(globalSettingsDir, "settings.json"), JSON.stringify({ testMode: true }));
const resolved = await resolveStartupDatabaseOptions({
globalSettingsDir,
env: { DATABASE_URL: "postgresql://operator:secret@production.example/fusion" },
});
expect(resolved.testDatabaseMode).toBe(true);
expect(resolved.backend.mode).toBe("embedded");
expect(resolved.backend.runtimeUrl).toBeNull();
expect(resolved.embeddedDataDir).toBe(join(globalSettingsDir, "embedded-postgres", "test"));
});
it("uses only the explicit test URL when test mode targets external PostgreSQL", async () => {
const testUrl = "postgresql://localhost:5432/fusion_test";
const resolved = await resolveStartupDatabaseOptions({
globalSettingsDir,
env: {
FUSION_TEST_MODE: "1",
DATABASE_URL: "postgresql://operator:secret@production.example/fusion",
FUSION_TEST_DATABASE_URL: testUrl,
},
});
expect(resolved.testDatabaseMode).toBe(true);
expect(resolved.backend.runtimeUrl).toBe(testUrl);
expect(resolved.backend.migrationUrl).toBe(testUrl);
});
it("keeps the normal production target when test mode is disabled", async () => {
const productionUrl = "postgresql://operator:secret@production.example/fusion";
const resolved = await resolveStartupDatabaseOptions({
globalSettingsDir,
env: { DATABASE_URL: productionUrl },
});
expect(resolved.testDatabaseMode).toBe(false);
expect(resolved.backend.runtimeUrl).toBe(productionUrl);
expect(resolved.embeddedDataDir).toBeUndefined();
});
});
describe("startup-factory: createTaskStoreForBackend resolution (no real boot)", () => {
let rootDir: string;

View File

@@ -2408,6 +2408,9 @@ export {
isEmbeddedPgOptedOut,
EMBEDDED_PG_ENV,
NO_EMBEDDED_PG_ENV,
TEST_MODE_ENV,
TEST_DATABASE_URL_ENV,
TEST_DATABASE_MIGRATION_URL_ENV,
} from "./postgres/index.js";
export type {
BackendMode,

View File

@@ -215,6 +215,9 @@ export {
isEmbeddedPgOptedOut,
EMBEDDED_PG_ENV,
NO_EMBEDDED_PG_ENV,
TEST_MODE_ENV,
TEST_DATABASE_URL_ENV,
TEST_DATABASE_MIGRATION_URL_ENV,
type BackendBootResult,
type CentralBackendLayerResult,
type CreateTaskStoreForBackendOptions,

View File

@@ -45,6 +45,7 @@ import { createLogger } from "../logger.js";
import { TaskStore } from "../store.js";
import {
resolveBackend,
resolveBackendWithOptions,
describeBackendForLog,
type ResolvedBackend,
} from "./backend-resolver.js";
@@ -136,6 +137,18 @@ export const EMBEDDED_PG_ENV = "FUSION_EMBEDDED_PG";
*/
export const NO_EMBEDDED_PG_ENV = "FUSION_NO_EMBEDDED_PG";
/** Explicit process-level opt-in for starting Fusion against its test database. */
export const TEST_MODE_ENV = "FUSION_TEST_MODE";
/** External PostgreSQL target used only while Fusion test mode is active. */
export const TEST_DATABASE_URL_ENV = "FUSION_TEST_DATABASE_URL";
/** Optional direct migration target paired with FUSION_TEST_DATABASE_URL. */
export const TEST_DATABASE_MIGRATION_URL_ENV = "FUSION_TEST_DATABASE_MIGRATION_URL";
function isTruthyEnvValue(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
/**
* Return true when the embedded PostgreSQL backend should be used in embedded
* mode (DATABASE_URL unset).
@@ -160,8 +173,7 @@ export function isEmbeddedPgRequested(env: NodeJS.ProcessEnv = process.env): boo
* Detect obsolete FUSION_NO_EMBEDDED_PG configuration for diagnostics.
*/
export function isEmbeddedPgOptedOut(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = (env[NO_EMBEDDED_PG_ENV] ?? "").trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
return isTruthyEnvValue(env[NO_EMBEDDED_PG_ENV]);
}
/**
@@ -443,6 +455,72 @@ async function bootSchemaBackendOnce(
}
}
export interface StartupDatabaseOptions {
readonly testDatabaseMode: boolean;
readonly backend: ResolvedBackend;
readonly embeddedDataDir?: string;
}
/**
* Resolve the database target before any schema, registry, or project write.
*
* Test mode is a storage boundary, not only a model-selection flag. A persisted
* global `testMode: true` (or FUSION_TEST_MODE=1) therefore ignores the normal
* DATABASE_URL. Operators may provide FUSION_TEST_DATABASE_URL for a dedicated
* external database; otherwise Fusion uses a separate embedded cluster under
* `<global settings>/embedded-postgres/test`.
*
* An explicit `backend` or `embeddedDataDir` remains authoritative for focused
* tests and programmatic embeddings.
*/
export async function resolveStartupDatabaseOptions(
options: Pick<CreateTaskStoreForBackendOptions, "env" | "backend" | "embeddedDataDir" | "globalSettingsDir"> = {},
): Promise<StartupDatabaseOptions> {
const env = options.env ?? process.env;
let globalTestMode = false;
// Vitest callers without a threaded temp global dir intentionally avoid the
// real operator settings file. Production and callers with an explicit dir
// can safely read the file-backed bootstrap setting before PostgreSQL opens.
const runningVitest = process.env.VITEST === "true";
if (options.globalSettingsDir || !runningVitest) {
const { GlobalSettingsStore } = await import("../global-settings.js");
globalTestMode = (await new GlobalSettingsStore(options.globalSettingsDir).getSettings()).testMode === true;
}
const testDatabaseMode = isTruthyEnvValue(env[TEST_MODE_ENV]) || globalTestMode;
if (!testDatabaseMode) {
return {
testDatabaseMode: false,
backend: options.backend ?? resolveBackend(env),
...(options.embeddedDataDir ? { embeddedDataDir: options.embeddedDataDir } : {}),
};
}
const backend = options.backend ?? resolveBackendWithOptions({
databaseUrl: env[TEST_DATABASE_URL_ENV] ?? null,
databaseMigrationUrl: env[TEST_DATABASE_MIGRATION_URL_ENV] ?? null,
});
if (backend.mode === "external") {
return {
testDatabaseMode: true,
backend,
...(options.embeddedDataDir ? { embeddedDataDir: options.embeddedDataDir } : {}),
};
}
let globalDir = options.globalSettingsDir;
if (!globalDir) {
const { resolveGlobalDir } = await import("../global-settings.js");
globalDir = resolveGlobalDir();
}
return {
testDatabaseMode: true,
backend,
embeddedDataDir: options.embeddedDataDir ?? join(globalDir, "embedded-postgres", "test"),
};
}
/**
* Open an unscoped PostgreSQL layer for the central project/node registry.
*
@@ -456,7 +534,12 @@ async function bootSchemaBackendOnce(
export async function createCentralBackendLayer(
options: Pick<CreateTaskStoreForBackendOptions, "env" | "backend" | "embeddedPgRequested" | "embeddedDataDir" | "poolMax" | "globalSettingsDir"> = {},
): Promise<CentralBackendLayerResult> {
const boot = await bootSchemaBackend(options, true);
const databaseOptions = await resolveStartupDatabaseOptions(options);
const boot = await bootSchemaBackend({
...options,
backend: databaseOptions.backend,
...(databaseOptions.embeddedDataDir ? { embeddedDataDir: databaseOptions.embeddedDataDir } : {}),
}, true);
const {
backend: resolvedBackend,
connections,
@@ -486,7 +569,7 @@ export async function createCentralBackendLayer(
connections.migration,
CENTRAL_SQLITE_MIGRATION_KEY,
);
if (!centralMigrationComplete && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) {
if (!databaseOptions.testDatabaseMode && !centralMigrationComplete && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) {
const report = await migrateSqliteToPostgres(connections.migration, [{
sqlitePath: legacyCentralPath,
pgSchema: "central",
@@ -633,7 +716,13 @@ export async function createTaskStoreForBackend(
options: CreateTaskStoreForBackendOptions,
): Promise<BackendBootResult> {
const env = options.env ?? process.env;
const backend = options.backend ?? resolveBackend(env);
const databaseOptions = await resolveStartupDatabaseOptions(options);
const backend = databaseOptions.backend;
const effectiveOptions: CreateTaskStoreForBackendOptions = {
...options,
backend,
...(databaseOptions.embeddedDataDir ? { embeddedDataDir: databaseOptions.embeddedDataDir } : {}),
};
const embeddedRequested = options.embeddedPgRequested ?? isEmbeddedPgRequested(env);
/*
@@ -667,7 +756,7 @@ export async function createTaskStoreForBackend(
let boot: SchemaBackendBootResult;
try {
const schemaT0 = Date.now();
boot = await bootSchemaBackend(options);
boot = await bootSchemaBackend(effectiveOptions);
log.log(`startup phase backend.schemaBackend: ${Date.now() - schemaT0}ms`);
} catch (err) {
const chain = describeErrorChain(err);
@@ -724,7 +813,9 @@ export async function createTaskStoreForBackend(
is not registered (legacy/unregistered single-project setups stay unbound,
matching their unfiltered readers).
*/
if (rootDir) {
// A test database starts clean by definition. Never seed it from the
// operator's retained production SQLite files.
if (rootDir && !databaseOptions.testDatabaseMode) {
try {
const fusionDir = join(rootDir, ".fusion");
const legacySqlitePath = join(fusionDir, "fusion.db");

View File

@@ -24,6 +24,7 @@ import {
shouldRunIsolationGuard,
defaultTestWorkerBudget,
createIsolatedHomeEnv,
createTestProcessEnv,
cleanupIsolatedHomePath,
knownIsolatedHomeBasenames,
__setCleanupRmSyncForTests,
@@ -1071,6 +1072,23 @@ test("createIsolatedHomeEnv: returns temp HOME/USERPROFILE pair without mutating
rmSync(isolatedHome, { recursive: true, force: true });
});
test("createTestProcessEnv: never forwards production database targets into tests", () => {
const input = {
NODE_ENV: "production",
DATABASE_URL: "postgresql://operator:secret@production.example/fusion",
DATABASE_MIGRATION_URL: "postgresql://operator:secret@production.example/fusion",
FUSION_PG_TEST_URL_BASE: "postgresql://localhost:5432",
};
const env = createTestProcessEnv(input);
assert.equal(env.NODE_ENV, "test");
assert.equal(env.DATABASE_URL, undefined);
assert.equal(env.DATABASE_MIGRATION_URL, undefined);
assert.equal(env.FUSION_PG_TEST_URL_BASE, input.FUSION_PG_TEST_URL_BASE);
assert.equal(input.DATABASE_URL, "postgresql://operator:secret@production.example/fusion");
});
test("createIsolatedHomeEnv: preserves a stable COREPACK_HOME outside the isolated HOME", () => {
const baseEnv = {

View File

@@ -1235,10 +1235,17 @@ export function createTestProcessEnv(env = process.env) {
FNXC:TestInfrastructure 2026-06-17-17:02:
Developer shells and release scripts can export NODE_ENV=production, but Vitest must resolve React, Testing Library, and Vite transforms through their test/development paths. Normalize spawned test processes here so pnpm test cannot inherit production React test-utils and stall/fail jsdom lanes.
*/
return {
const nextEnv = {
...env,
NODE_ENV: "test",
};
// A developer may legitimately export DATABASE_URL for their normal Fusion
// runtime. Test commands must never inherit that production target. PostgreSQL
// integration tests use the dedicated FUSION_PG_TEST_* harness variables or
// set a per-test DATABASE_URL after process setup.
delete nextEnv.DATABASE_URL;
delete nextEnv.DATABASE_MIGRATION_URL;
return nextEnv;
}
const fullSuiteEnv = {