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:
7
.changeset/safe-test-database.md
Normal file
7
.changeset/safe-test-database.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user