/** * Unit tests for authoredComposeFileArgs: the docker compose global-flag prefix * (`-f` per file, `-p `, optional `--project-directory`) derived from a * stack's applied deploy spec. * * Contract: * - no applied spec (single-file / non-git) -> [] so runtime stays plain auto-discovery * - a multi-file applied spec -> ordered -f flags, then -p , then * --project-directory when a context dir is set * - any spec file path or context dir that is absolute / contains ".." throws * before any args are returned (it is spliced straight into child-process argv) */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import path from 'path'; import fs from 'fs'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; let tmpDir: string; let authoredComposeFileArgs: typeof import('../utils/authoredComposeArgs').authoredComposeFileArgs; let authoredComposeEnvFileArgs: typeof import('../utils/authoredComposeArgs').authoredComposeEnvFileArgs; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; beforeAll(async () => { tmpDir = await setupTestDb(); ({ authoredComposeFileArgs, authoredComposeEnvFileArgs } = await import('../utils/authoredComposeArgs')); ({ DatabaseService } = await import('../services/DatabaseService')); ({ NodeRegistry } = await import('../services/NodeRegistry')); }); afterAll(() => { cleanupTestDb(tmpDir); }); beforeEach(() => { const db = DatabaseService.getInstance(); for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name); }); /** Seed a git-source row (no applied spec yet) so setGitSourceAppliedSpec can target it. */ function seedSource(stackName: string, composePaths: string[]): void { DatabaseService.getInstance().upsertGitSource({ stack_name: stackName, repo_url: 'https://github.com/example/repo.git', branch: 'main', compose_path: composePaths[0], compose_paths: composePaths, context_dir: null, sync_env: false, env_path: null, auth_type: 'none', encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, last_applied_content_hash: null, pending_commit_sha: null, pending_compose_content: null, pending_env_content: null, pending_fetched_at: null, last_debounce_at: null, }); } describe('authoredComposeFileArgs', () => { it('returns [] for a stack with no git source at all', () => { expect(authoredComposeFileArgs('no-such-stack')).toEqual([]); }); it('returns [] for a git-source stack with no applied spec (single-file)', () => { seedSource('single-stack', ['compose.yaml']); // No setGitSourceAppliedSpec call -> applied_deploy_spec stays null. expect(authoredComposeFileArgs('single-stack')).toEqual([]); }); it('returns ordered -f / -p / --project-directory for a multi-file spec', () => { const stackName = 'multi-stack'; seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: 'app', }); const args = authoredComposeFileArgs(stackName); const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId()); const expectedCtx = path.resolve(baseDir, stackName, 'app'); expect(args).toEqual([ '-f', 'compose.yaml', '-f', 'infra/prod.yml', '-p', stackName, '--project-directory', expectedCtx, ]); }); it('omits --project-directory when the spec has no context dir', () => { const stackName = 'multi-no-ctx'; seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: null, }); expect(authoredComposeFileArgs(stackName)).toEqual([ '-f', 'compose.yaml', '-f', 'infra/prod.yml', '-p', stackName, ]); }); it('throws when a spec file path is absolute', () => { const stackName = 'abs-file'; seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', '/etc/passwd'], contextDir: null, }); expect(() => authoredComposeFileArgs(stackName)).toThrow(); }); it('throws when a spec file path contains a ".." traversal', () => { const stackName = 'dotdot-file'; seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', '../escape.yml'], contextDir: null, }); expect(() => authoredComposeFileArgs(stackName)).toThrow(); }); it('throws when the context dir contains a ".." traversal', () => { const stackName = 'dotdot-ctx'; seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: '../escape', }); expect(() => authoredComposeFileArgs(stackName)).toThrow(); }); }); /** * authoredComposeEnvFileArgs: the `--env-file /.env` flag a multi-file * Git deploy needs when a context dir is set. With `--project-directory `, * Compose treats the context dir as the project directory and stops auto-finding * the root `.env` Sencho writes, so validation (which passes --env-file) and * deploy would otherwise resolve different env. Single-file / no-context stacks * keep Compose's default `.env` discovery from the stack dir, so they get no flag. */ describe('authoredComposeEnvFileArgs', () => { /** Create the on-disk stack directory and optionally a root .env for it. */ function makeStackDir(stackName: string, withEnv: boolean): string { const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId()); const stackDir = path.join(baseDir, stackName); fs.mkdirSync(stackDir, { recursive: true }); if (withEnv) fs.writeFileSync(path.join(stackDir, '.env'), 'TAG=1\n', 'utf-8'); else fs.rmSync(path.join(stackDir, '.env'), { force: true }); return stackDir; } it('returns [] for a stack with no git source at all', async () => { expect(await authoredComposeEnvFileArgs('no-such-stack')).toEqual([]); }); it('returns [] when the context dir is set but no .env exists', async () => { const stackName = 'ctx-no-env'; seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: 'app', }); makeStackDir(stackName, false); expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]); }); it('returns [] when a .env exists but the spec has no context dir', async () => { // No --project-directory, so the project dir stays the stack dir (cwd) and // Compose auto-discovers the root .env; an explicit flag is not needed. const stackName = 'env-no-ctx'; seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: null, }); makeStackDir(stackName, true); expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]); }); it('returns --env-file /.env when a context dir is set and a .env exists', async () => { const stackName = 'ctx-with-env'; seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: 'app', }); const stackDir = makeStackDir(stackName, true); expect(await authoredComposeEnvFileArgs(stackName)).toEqual([ '--env-file', path.join(stackDir, '.env'), ]); }); it('returns [] for a stack name that escapes the compose base (path-injection guard)', async () => { // The inline barrier rejects a traversal name before the fs access, so no // --env-file is emitted for a path outside the compose base. const stackName = '../escape'; seedSource(stackName, ['infra/base.yml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: 'app', }); expect(await authoredComposeEnvFileArgs(stackName)).toEqual([]); }); it('rethrows a non-ENOENT access error instead of silently dropping the env file', async () => { // An EACCES on an existing .env must surface, not be treated as "no env // file": dropping --env-file there would deploy a different effective config // than the one validated. const stackName = 'ctx-eacces'; seedSource(stackName, ['compose.yaml', 'infra/prod.yml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml', 'infra/prod.yml'], contextDir: 'app', }); const spy = vi.spyOn(fs.promises, 'access').mockRejectedValueOnce( Object.assign(new Error('permission denied'), { code: 'EACCES' }), ); await expect(authoredComposeEnvFileArgs(stackName)).rejects.toThrow(/permission denied/); spy.mockRestore(); }); }); describe('candidateValidationEnvFileArgs', () => { let candidateValidationEnvFileArgs: typeof import('../utils/authoredComposeArgs').candidateValidationEnvFileArgs; beforeAll(async () => { ({ candidateValidationEnvFileArgs } = await import('../utils/authoredComposeArgs')); }); function makeStackDir(stackName: string, withEnv: boolean): string { const baseDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId()); const stackDir = path.join(baseDir, stackName); fs.mkdirSync(stackDir, { recursive: true }); if (withEnv) fs.writeFileSync(path.join(stackDir, '.env'), 'TAG=live\n', 'utf-8'); else fs.rmSync(path.join(stackDir, '.env'), { force: true }); return stackDir; } it('uses candidate .env for context-dir sync-env, not the live stack .env', async () => { const stackName = 'val-sync-env'; seedSource(stackName, ['compose.yaml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml'], contextDir: 'app', }); const liveDir = makeStackDir(stackName, true); const candidateAbs = path.join(tmpDir, 'candidate-sync-env'); fs.mkdirSync(candidateAbs, { recursive: true }); fs.writeFileSync(path.join(candidateAbs, '.env'), 'TAG=candidate\n', 'utf-8'); const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const args = await candidateValidationEnvFileArgs({ stackName, nodeId, candidateAbs, contextDir: 'app', syncEnv: true, }); expect(args).toEqual(['--env-file', path.resolve(candidateAbs, '.env')]); expect(args).not.toContain(path.resolve(liveDir, '.env')); }); it('does not fall back to a live .env when sync-env omits the candidate file', async () => { const stackName = 'val-sync-env-removed'; seedSource(stackName, ['compose.yaml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml'], contextDir: 'app', }); const liveDir = makeStackDir(stackName, true); const candidateAbs = path.join(tmpDir, 'candidate-sync-env-removed'); fs.mkdirSync(candidateAbs, { recursive: true }); const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const args = await candidateValidationEnvFileArgs({ stackName, nodeId, candidateAbs, contextDir: 'app', syncEnv: true, }); expect(args).toEqual([]); expect(args).not.toContain(path.resolve(liveDir, '.env')); const deploy = await authoredComposeEnvFileArgs(stackName, nodeId); expect(deploy).toEqual(['--env-file', path.resolve(liveDir, '.env')]); }); it('falls back to the live .env when an unmanaged candidate file is absent', async () => { const stackName = 'val-unmanaged-env-fallback'; seedSource(stackName, ['compose.yaml']); DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, { files: ['compose.yaml'], contextDir: 'app', }); const liveDir = makeStackDir(stackName, true); const candidateAbs = path.join(tmpDir, 'candidate-unmanaged-env'); fs.mkdirSync(candidateAbs, { recursive: true }); const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const args = await candidateValidationEnvFileArgs({ stackName, nodeId, candidateAbs, contextDir: 'app', syncEnv: false, }); expect(args).toEqual(['--env-file', path.resolve(liveDir, '.env')]); }); it('uses only configured project env files even when candidate .env exists', async () => { const stackName = 'val-project-env'; seedSource(stackName, ['compose.yaml']); const liveDir = makeStackDir(stackName, true); fs.writeFileSync(path.join(liveDir, 'prod.env'), 'FOO=1\n', 'utf-8'); DatabaseService.getInstance().setStackProjectEnvFiles( NodeRegistry.getInstance().getDefaultNodeId(), stackName, ['prod.env'], ); const candidateAbs = path.join(tmpDir, 'candidate-project-env'); fs.mkdirSync(candidateAbs, { recursive: true }); fs.writeFileSync(path.join(candidateAbs, '.env'), 'TAG=candidate\n', 'utf-8'); const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); const validation = await candidateValidationEnvFileArgs({ stackName, nodeId, candidateAbs, contextDir: 'app', syncEnv: true, }); const deploy = await authoredComposeEnvFileArgs(stackName, nodeId); expect(validation).toEqual(deploy); expect(validation).toEqual(['--env-file', path.resolve(liveDir, 'prod.env')]); expect(validation.join(' ')).not.toContain(path.join(candidateAbs, '.env')); }); it('throws when a configured project env file is missing', async () => { const stackName = 'val-missing-env'; seedSource(stackName, ['compose.yaml']); makeStackDir(stackName, false); DatabaseService.getInstance().setStackProjectEnvFiles( NodeRegistry.getInstance().getDefaultNodeId(), stackName, ['missing.env'], ); const candidateAbs = path.join(tmpDir, 'candidate-missing-env'); fs.mkdirSync(candidateAbs, { recursive: true }); const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); await expect(candidateValidationEnvFileArgs({ stackName, nodeId, candidateAbs, contextDir: null, syncEnv: false, })).rejects.toThrow(/missing/); }); });