/** * resolveStackEnvSources: env_file existence metadata, the project .env * interpolation source, inline environment keys, interpolation refs, and the * multi-file Git deploy-spec path. Real filesystem + DB; no Docker. */ import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import fs from 'fs'; import path from 'path'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { resolveStackEnvSources } from '../helpers/envFileResolution'; let tmpDir: string; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let nodeId: number; function composeDir(): string { return process.env.COMPOSE_DIR as string; } function writeStack(stack: string, files: Record): void { const dir = path.join(composeDir(), stack); fs.mkdirSync(dir, { recursive: true }); for (const [name, content] of Object.entries(files)) { const target = path.join(dir, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content); } } beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id; }); afterAll(() => cleanupTestDb(tmpDir)); afterEach(() => vi.restoreAllMocks()); describe('resolveStackEnvSources', () => { it('models the project .env as the interpolation source and reads inline keys + refs', async () => { writeStack('s1', { 'compose.yaml': 'services:\n web:\n image: nginx:${TAG:-latest}\n environment:\n APP_PORT: "8080"\n FROM_SHELL: ${FROM_SHELL}\n', '.env': 'TAG=1.0\n', }); const r = await resolveStackEnvSources(nodeId, 's1'); const dotenv = r.envFiles.find(f => f.isInterpolationSource); expect(dotenv?.existence).toBe('present'); expect(dotenv?.isInjectionSource).toBe(false); expect(r.inlineEnvKeysByService.web).toContain('APP_PORT'); expect(r.interpolationRefs.map(x => x.name).sort()).toEqual(['FROM_SHELL', 'TAG']); }); it('flags a missing required env_file but not an optional one', async () => { writeStack('s2', { 'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - ./present.env\n - ./gone.env\n - path: ./optional.env\n required: false\n', 'present.env': 'A=1\n', }); const r = await resolveStackEnvSources(nodeId, 's2'); const byRaw = (raw: string) => r.envFiles.find(f => f.rawPaths.includes(raw)); expect(byRaw('./present.env')?.existence).toBe('present'); expect(byRaw('./gone.env')).toMatchObject({ existence: 'missing', required: true, isInjectionSource: true }); expect(byRaw('./optional.env')).toMatchObject({ existence: 'missing', required: false }); }); it('marks interpolated and escaping env_file paths as unverifiable', async () => { writeStack('s3', { 'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - ${ENV_DIR}/x.env\n - ../escape.env\n', }); const r = await resolveStackEnvSources(nodeId, 's3'); for (const f of r.envFiles.filter(x => x.isInjectionSource)) { expect(f.existence).toBe('unverifiable'); expect(f.resolvedPath).toBeNull(); } }); it('treats .env doubling as env_file: .env as one physical file with both roles', async () => { writeStack('s4', { 'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - .env\n', '.env': 'SHARED=1\n', }); const r = await resolveStackEnvSources(nodeId, 's4'); const dotenvFiles = r.envFiles.filter(f => f.isInterpolationSource); expect(dotenvFiles).toHaveLength(1); expect(dotenvFiles[0]).toMatchObject({ isInterpolationSource: true, isInjectionSource: true, existence: 'present' }); }); it('reads env_file declared only in a Git multi-file override', async () => { writeStack('s5', { 'compose.yaml': 'services:\n web:\n image: nginx\n', 'override.yaml': 'services:\n web:\n env_file:\n - ./override.env\n', 'override.env': 'O=1\n', }); vi.spyOn(DatabaseService.getInstance(), 'getGitSource').mockReturnValue({ applied_deploy_spec: { files: ['compose.yaml', 'override.yaml'], contextDir: null }, } as unknown as ReturnType); const r = await resolveStackEnvSources(nodeId, 's5'); expect(r.composeFiles).toHaveLength(2); expect(r.envFiles.some(f => f.rawPaths.includes('./override.env') && f.existence === 'present')).toBe(true); }); it('resolves a nested override env_file relative to its compose file, not the stack root', async () => { writeStack('s6', { 'compose.yaml': 'services:\n web:\n image: nginx\n', 'infra/prod.yml': 'services:\n web:\n env_file:\n - ./prod.env\n', 'infra/prod.env': 'NESTED=1\n', }); vi.spyOn(DatabaseService.getInstance(), 'getGitSource').mockReturnValue({ applied_deploy_spec: { files: ['compose.yaml', 'infra/prod.yml'], contextDir: null }, } as unknown as ReturnType); const r = await resolveStackEnvSources(nodeId, 's6'); const f = r.envFiles.find(x => x.rawPaths.includes('./prod.env')); expect(f).toMatchObject({ existence: 'present', isInjectionSource: true }); expect(f?.resolvedPath?.endsWith(path.join('infra', 'prod.env'))).toBe(true); }); });