diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts index 42fdf470..4096acb7 100644 --- a/apps/gateway/src/app.module.spec.ts +++ b/apps/gateway/src/app.module.spec.ts @@ -1,64 +1,210 @@ import 'reflect-metadata'; +import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { MODULE_METADATA } from '@nestjs/common/constants.js'; import { describe, expect, it, vi } from 'vitest'; interface ComposedModuleGraph { imports: readonly unknown[]; federationModule: unknown; + bootLogLines: readonly string[]; +} + +interface FileSnapshot { + exists: boolean; + contents: Buffer | null; +} + +type StorageTier = 'local' | 'standalone' | 'federated'; + +interface ModuleGraphFixtureOptions { + cwdPath: string; + rootEnvMode?: 'present' | 'absent'; + rootTier?: StorageTier; + rootEnvContents?: string; + secret?: string; + gatewayLocalTier?: StorageTier; + gatewayLocalEnvContents?: string; + daemonEnvContents?: string; + inheritedTier?: StorageTier; + expectedProcessTier?: StorageTier; + setup?: () => Promise; } const MODULE_IMPORT_TIMEOUT_MS = 30_000; +const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env'; +const DAEMON_DOTENV_LABEL = 'daemon .env'; +const specDir = dirname(fileURLToPath(import.meta.url)); +const monorepoRootDir = resolve(specDir, '../../..'); +const gatewayDir = resolve(specDir, '..'); +const rootEnvPath = join(monorepoRootDir, '.env'); +const gatewayLocalEnvPath = join(gatewayDir, '.env'); -function restoreEnvironmentVariable(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; +function snapshotProcessEnv(): Record { + return { ...process.env }; +} + +function restoreProcessEnv(snapshot: Record): void { + for (const key of Object.keys(process.env)) { + if (!(key in snapshot)) { + delete process.env[key]; + } + } + + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + continue; + } + + process.env[key] = value; + } +} + +async function snapshotFile(path: string): Promise { + if (!existsSync(path)) { + return { exists: false, contents: null }; + } + + return { exists: true, contents: await readFile(path) }; +} + +async function restoreFile(path: string, snapshot: FileSnapshot): Promise { + if (!snapshot.exists) { + await rm(path, { force: true }); return; } - process.env[name] = value; + await writeFile(path, snapshot.contents ?? Buffer.alloc(0)); +} + +function singleBootLogLine(bootLogLines: readonly string[]): string { + expect(bootLogLines).toHaveLength(1); + const [bootLogLine] = bootLogLines; + if (bootLogLine === undefined) { + throw new Error('Expected a single boot log line'); + } + + return bootLogLine; +} + +function expectBootLogLine( + bootLogLines: readonly string[], + tier: StorageTier, + source: string, +): void { + const bootLogLine = singleBootLogLine(bootLogLines); + + expect(bootLogLine).toContain(`storage tier=${tier}`); + expect(bootLogLine).toContain(`source=${source}`); } async function loadModuleGraphFromDotenv( - tier: 'local' | 'standalone' | 'federated', + options: ModuleGraphFixtureOptions, ): Promise { - 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'); + const originalEnv = snapshotProcessEnv(); + const rootSnapshot = await snapshotFile(rootEnvPath); + const gatewayLocalSnapshot = await snapshotFile(gatewayLocalEnvPath); + const isolatedHome = await mkdtemp(join(tmpdir(), 'mosaic-gateway-home-')); + const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined); - 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); + await mkdir(options.cwdPath, { recursive: true }); try { - expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined(); - await import('./env.js'); - expect(process.env['MOSAIC_STORAGE_TIER']).toBe(tier); + if ((options.rootEnvMode ?? 'present') === 'absent') { + if ( + options.rootEnvContents !== undefined || + options.rootTier !== undefined || + options.secret !== undefined + ) { + throw new Error('Expected no root env fixture values when rootEnvMode is absent'); + } - const { AppModule } = await import('./app.module.js'); - const { FederationModule } = await import('./federation/federation.module.js'); - const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule); + await rm(rootEnvPath, { force: true }); + } else { + if (options.rootEnvContents === undefined && options.rootTier === undefined) { + throw new Error('Expected rootTier or rootEnvContents'); + } - if (!Array.isArray(imports)) { - throw new Error('AppModule imports metadata is not an array'); + const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`; + const rootFixtureWithSecret = options.secret + ? `${rootFixture}BETTER_AUTH_SECRET=${options.secret}\n` + : rootFixture; + + await writeFile(rootEnvPath, rootFixtureWithSecret, 'utf8'); } - return { imports, federationModule: FederationModule }; + if (options.daemonEnvContents !== undefined) { + const daemonEnvPath = join(isolatedHome, '.config', 'mosaic', 'gateway', '.env'); + await mkdir(dirname(daemonEnvPath), { recursive: true }); + await writeFile(daemonEnvPath, options.daemonEnvContents, 'utf8'); + } + + if (options.gatewayLocalEnvContents !== undefined) { + await writeFile(gatewayLocalEnvPath, options.gatewayLocalEnvContents, 'utf8'); + } else if (options.gatewayLocalTier !== undefined) { + await writeFile( + gatewayLocalEnvPath, + `MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`, + 'utf8', + ); + } else if (existsSync(gatewayLocalEnvPath)) { + await rm(gatewayLocalEnvPath, { force: true }); + } + + process.env['HOME'] = isolatedHome; + delete process.env['MOSAIC_STORAGE_TIER']; + delete process.env['DATABASE_URL']; + delete process.env['VALKEY_URL']; + + await options.setup?.(); + + if (options.inheritedTier !== undefined) { + process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier; + } + + vi.resetModules(); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(options.cwdPath); + + try { + if (options.inheritedTier === undefined) { + expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined(); + } else { + expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier); + } + + await import('./env.js'); + expect(process.env['MOSAIC_STORAGE_TIER']).toBe( + options.expectedProcessTier ?? options.rootTier, + ); + + 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, + bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string => + args.map((value: unknown): string => String(value)).join(' '), + ), + }; + } finally { + cwdSpy.mockRestore(); + } } finally { - cwdSpy.mockRestore(); - restoreEnvironmentVariable('HOME', originalHome); - restoreEnvironmentVariable('MOSAIC_STORAGE_TIER', originalTier); - restoreEnvironmentVariable('DATABASE_URL', originalDatabaseUrl); - await rm(fixtureRoot, { recursive: true, force: true }); + consoleInfoSpy.mockRestore(); + restoreProcessEnv(originalEnv); + await restoreFile(rootEnvPath, rootSnapshot); + await restoreFile(gatewayLocalEnvPath, gatewayLocalSnapshot); + await rm(isolatedHome, { recursive: true, force: true }); } } @@ -74,22 +220,179 @@ describe('AppModule federation gating', (): void => { expect(envImportIndex).toBeLessThan(appModuleImportIndex); }); - it.each(['local', 'standalone'] as const)( - 'does not register FederationModule for the %s tier', - async (tier): Promise => { - const graph = await loadModuleGraphFromDotenv(tier); + it( + 'ignores attacker-writable ambient cwd dotenv files', + async (): Promise => { + const ambientRoot = await mkdtemp(join(tmpdir(), 'mosaic-gateway-ambient-')); + const ambientCwd = join(ambientRoot, 'sandbox', 'cwd'); - expect(graph.imports).not.toContain(graph.federationModule); + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath: ambientCwd, + rootTier: 'local', + setup: async (): Promise => { + await writeFile(join(ambientCwd, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8'); + await writeFile(join(ambientRoot, '.env'), 'MOSAIC_STORAGE_TIER=federated\n', 'utf8'); + }, + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL); + } finally { + await rm(ambientRoot, { recursive: true, force: true }); + } }, MODULE_IMPORT_TIMEOUT_MS, ); it( - 'registers FederationModule when federated tier is supplied only by a dotenv file', + 'logs standalone from a monorepo-root .env DATABASE_URL fallback', async (): Promise => { - const graph = await loadModuleGraphFromDotenv('federated'); + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); - expect(graph.imports).toContain(graph.federationModule); + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath, + rootEnvContents: 'DATABASE_URL=fixture-database-url\n', + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'daemon .env wins over monorepo-root and gateway-local tier values', + async (): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath, + rootTier: 'local', + gatewayLocalTier: 'federated', + daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n', + expectedProcessTier: 'standalone', + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values', + async (): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath, + rootTier: 'local', + gatewayLocalTier: 'federated', + daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n', + inheritedTier: 'standalone', + expectedProcessTier: 'standalone', + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment'); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'gateway-local .env configures the tier and source when the monorepo-root .env is absent', + async (): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath, + rootEnvMode: 'absent', + gatewayLocalTier: 'federated', + expectedProcessTier: 'federated', + }); + + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env'); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'monorepo-root .env wins over gateway-local tier values', + async (): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath, + rootTier: 'standalone', + gatewayLocalTier: 'federated', + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it.each(['local', 'standalone'] as const)( + 'does not register FederationModule for the %s tier', + async (tier): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath, + rootTier: tier, + }); + + expect(graph.imports).not.toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } + }, + MODULE_IMPORT_TIMEOUT_MS, + ); + + it( + 'registers FederationModule when federated tier is supplied by the anchored monorepo root .env', + async (): Promise => { + const cwdPath = await mkdtemp(join(tmpdir(), 'mosaic-gateway-cwd-')); + + try { + const graph = await loadModuleGraphFromDotenv({ + cwdPath, + rootTier: 'federated', + secret: 'super-secret-fixture-value', + }); + + expect(graph.imports).toContain(graph.federationModule); + expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL); + expect(singleBootLogLine(graph.bootLogLines)).not.toContain('super-secret-fixture-value'); + } finally { + await rm(cwdPath, { recursive: true, force: true }); + } }, MODULE_IMPORT_TIMEOUT_MS, ); diff --git a/apps/gateway/src/env.ts b/apps/gateway/src/env.ts index 840c6118..adfeaa77 100644 --- a/apps/gateway/src/env.ts +++ b/apps/gateway/src/env.ts @@ -1,14 +1,80 @@ import { config } from 'dotenv'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { detectFromEnv, loadConfig } from '@mosaicstack/config'; -// Load .env from daemon config dir (global install / daemon mode). -// It takes precedence over file-based local-dev configuration. +type TierSource = + | 'process environment' + | 'daemon .env' + | 'monorepo-root .env' + | 'gateway-local .env' + | 'default'; + +type BootSource = TierSource | 'mosaic.config.json'; + +const here = dirname(fileURLToPath(import.meta.url)); const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env'); -if (existsSync(daemonEnv)) config({ path: daemonEnv }); +const monorepoRootEnv = resolve(here, '../../..', '.env'); +const gatewayLocalEnv = resolve(here, '..', '.env'); -// 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(); +const inheritedTier = process.env['MOSAIC_STORAGE_TIER']; +let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment'; +const inheritedDatabaseUrl = process.env['DATABASE_URL']; +let databaseUrlSource: TierSource = + inheritedDatabaseUrl === undefined ? 'default' : 'process environment'; + +function loadAnchoredDotenv( + path: string, + sourceLabel: Exclude, +): void { + if (!existsSync(path)) { + return; + } + + const beforeTier = process.env['MOSAIC_STORAGE_TIER']; + const beforeDatabaseUrl = process.env['DATABASE_URL']; + config({ path, quiet: true }); + + if ( + beforeTier === undefined && + process.env['MOSAIC_STORAGE_TIER'] !== undefined && + tierSource === 'default' + ) { + tierSource = sourceLabel; + } + + if ( + beforeDatabaseUrl === undefined && + process.env['DATABASE_URL'] !== undefined && + databaseUrlSource === 'default' + ) { + databaseUrlSource = sourceLabel; + } +} + +// Load .env from daemon config dir (global install / daemon mode) first. +// It takes precedence over file-based local-dev configuration. +loadAnchoredDotenv(daemonEnv, 'daemon .env'); + +// Load .env from the anchored monorepo root, then fill any remaining values +// from apps/gateway/.env when present. +loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env'); +loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env'); + +const envOnlyTier = detectFromEnv().tier; +const resolvedTier = loadConfig().tier; + +let source: BootSource; +if (resolvedTier !== envOnlyTier) { + source = 'mosaic.config.json'; +} else if (tierSource !== 'default') { + source = tierSource; +} else if (envOnlyTier === 'standalone' && databaseUrlSource !== 'default') { + source = databaseUrlSource; +} else { + source = 'default'; +} + +console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);