fix(gateway): load dotenv before federation tier gate (#1138)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface ComposedModuleGraph {
|
||||
imports: readonly unknown[];
|
||||
federationModule: unknown;
|
||||
}
|
||||
|
||||
const MODULE_IMPORT_TIMEOUT_MS = 30_000;
|
||||
|
||||
function restoreEnvironmentVariable(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
|
||||
process.env[name] = value;
|
||||
}
|
||||
|
||||
async function loadModuleGraphFromDotenv(
|
||||
tier: 'local' | 'standalone' | 'federated',
|
||||
): Promise<ComposedModuleGraph> {
|
||||
const originalHome = process.env['HOME'];
|
||||
const originalTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
const originalDatabaseUrl = process.env['DATABASE_URL'];
|
||||
const fixtureRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-module-'));
|
||||
const gatewayCwd = join(fixtureRoot, 'apps', 'gateway');
|
||||
|
||||
await mkdir(gatewayCwd, { recursive: true });
|
||||
await writeFile(join(fixtureRoot, '.env'), `MOSAIC_STORAGE_TIER=${tier}\n`, 'utf8');
|
||||
|
||||
process.env['HOME'] = join(fixtureRoot, 'home');
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
vi.resetModules();
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(gatewayCwd);
|
||||
|
||||
try {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||
await import('./env.js');
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(tier);
|
||||
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const { FederationModule } = await import('./federation/federation.module.js');
|
||||
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||
|
||||
if (!Array.isArray(imports)) {
|
||||
throw new Error('AppModule imports metadata is not an array');
|
||||
}
|
||||
|
||||
return { imports, federationModule: FederationModule };
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
restoreEnvironmentVariable('HOME', originalHome);
|
||||
restoreEnvironmentVariable('MOSAIC_STORAGE_TIER', originalTier);
|
||||
restoreEnvironmentVariable('DATABASE_URL', originalDatabaseUrl);
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppModule federation gating', (): void => {
|
||||
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||
|
||||
expect(envImportIndex).toBeGreaterThan(-1);
|
||||
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||
});
|
||||
|
||||
it.each(['local', 'standalone'] as const)(
|
||||
'does not register FederationModule for the %s tier',
|
||||
async (tier): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv(tier);
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'registers FederationModule when federated tier is supplied only by a dotenv file',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv('federated');
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -31,7 +31,9 @@ import { loadConfig } from '@mosaicstack/config';
|
||||
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||
// federation profile "must not start in non-federated dev").
|
||||
// federation profile "must not start in non-federated dev"). The gateway
|
||||
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||
// configuration is visible here.
|
||||
const federationEnabled = loadConfig().tier === 'federated';
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode).
|
||||
// It takes precedence over file-based local-dev configuration.
|
||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||
|
||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter),
|
||||
// then fill any remaining values from apps/gateway/.env when present.
|
||||
config({ path: resolve(process.cwd(), '../../.env') });
|
||||
config();
|
||||
@@ -1,18 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode).
|
||||
// Loaded first so monorepo .env can override for local dev.
|
||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||
|
||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||
config({ path: resolve(process.cwd(), '../../.env') });
|
||||
config(); // Also load apps/gateway/.env if present (overrides)
|
||||
|
||||
import './env.js';
|
||||
import './tracing.js';
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
|
||||
Reference in New Issue
Block a user