Files
sencho/backend/src/__tests__/compose-images.test.ts
T
Anso a698aaa926 feat: add per-stack project env file selection for Docker Compose (#1457)
* feat: add per-stack project env file selection for Docker Compose

Allow users to configure an ordered list of env files per stack that serve
as the project environment file(s) for Docker Compose ${VAR} interpolation.
The selected files are passed via repeated --env-file flags during all
compose commands.

Backend:
- Add stack_project_env_files table (node-scoped, ordered)
- Extend authoredComposeEnvFileArgs to emit --env-file for configured files
- Add GET/PUT /stacks/:name/project-env-files and /candidates endpoints
- Update resolveStackEnvSources to use configured files as interpolation source
- Update resolveAllEnvFilePaths to merge injection + interpolation sources
- Add discoverStackLocalEnvFiles for candidate discovery
- Extend backupStackFiles and snapshotStackFiles for project env files
- Add project-env-files capability to CapabilityRegistry

Frontend:
- Add project env file selector to EnvironmentPanel (capability-gated)
- Update EditorView banner to generic "project environment file" language
- Add project-env-files capability to capabilities.ts

Issue: #1454

* fix: add realpath validation, clear all stale backup files, reject nested paths

- authoredComposeEnvFileArgs: use fsPromises.realpath + isPathWithinBase
  for symlink escape defense at use time
- backupStackFiles: clear ALL non-marker files from backup slot before
  writing, not just PROTECTED_STACK_FILES (handles stale old.env)
- PUT project-env-files: reject paths containing / or \ (root-level
  only, matching Compose auto-discovery behavior)

* fix: add getStackProjectEnvFiles to compose-service mock

The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles
on the DatabaseService singleton. The compose-service mesh-override
tests mock that singleton without the new method, causing 6 failures.
Add getStackProjectEnvFiles: () => [] (empty = fall back to legacy
behavior, which is what these tests exercise).

* fix: add getStackProjectEnvFiles to remaining service mocks

The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles,
which is missing from the mock in compose-images.test.ts (6 failures)
and image-update-service.test.ts (proactive fix).

* fix: apply inline path-injection barrier at fs sink for CodeQL

The PUT project-env-files route resolved paths via isPathWithinBase
before calling fsp.stat, but CodeQL does not credit a containment check
separated from the sink. Apply the canonical inline barrier pattern
(path.resolve + startsWith at the sink) used throughout the codebase.

* fix: resolve stackDir from the same canonical root as safePath

Prevents a containment bypass when the compose base directory is
a symlink: stackDir was previously joined from the unresolved
baseDir while the inline barrier used path.resolve(baseDir),
which could differ for symlinked paths. Now both stackDir and
safePath are resolved from a single canonical root, then each is
containment-checked against it.

* fix: remove unused isPathWithinBase import

The inline path-injection barrier refactor replaced isPathWithinBase
with an inline startsWith check at the fs sink, so the import is now
unused and fails ESLint no-unused-vars.
2026-06-25 18:03:05 -04:00

171 lines
5.5 KiB
TypeScript

/**
* Exercises ComposeService.listStackImages, the helper the policy gate calls
* to enumerate the images a stack will pull before `docker compose up`.
*
* The stdout from `docker compose config --images` can contain duplicates
* (multiple services running the same image), trailing whitespace, blank
* lines, and `sha256:` digest lines we must not pass to Trivy. The gate
* feeds this list directly to `scanImagePreflight`, so dedupe + filter
* correctness here directly affects what gets scanned and what silently
* passes through.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/test/compose',
}),
},
}));
vi.mock('../services/DockerController', () => ({
default: {
getInstance: () => ({
getContainersByStack: vi.fn().mockResolvedValue([]),
removeContainers: vi.fn().mockResolvedValue([]),
getDocker: () => ({
listContainers: vi.fn().mockResolvedValue([]),
}),
}),
},
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined, getStackProjectEnvFiles: () => [] }) },
}));
vi.mock('../services/RegistryService', () => ({
RegistryService: {
getInstance: () => ({
resolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
}),
},
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
backupStackFiles: vi.fn().mockResolvedValue(undefined),
restoreStackFiles: vi.fn().mockResolvedValue(undefined),
}),
},
}));
vi.mock('../services/LogFormatter', () => ({
LogFormatter: { formatLine: (line: string) => line },
}));
import { ComposeService } from '../services/ComposeService';
function mockComposeConfig(stdout: string, exitCode = 0): void {
mockSpawn.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
Promise.resolve().then(() => {
if (stdout) proc.stdout.emit('data', Buffer.from(stdout));
proc.emit('close', exitCode);
});
return proc;
});
}
describe('ComposeService.listStackImages', () => {
beforeEach(() => {
mockSpawn.mockReset();
});
it('returns the list of images, trimmed and deduped', async () => {
mockComposeConfig('nginx:1.14\nredis:7\nnginx:1.14\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('invokes `docker compose config --images` in the stack directory', async () => {
mockComposeConfig('nginx:1.14\n');
await ComposeService.getInstance(1).listStackImages('my-stack');
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'config', '--images'],
expect.objectContaining({ cwd: expect.stringContaining('my-stack') }),
);
});
it('filters out sha256 digest lines', async () => {
mockComposeConfig('nginx:1.14\nsha256:deadbeefcafebabe\nredis:7\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('handles trailing / leading whitespace and CRLF endings', async () => {
mockComposeConfig(' nginx:1.14 \r\n\r\n\tredis:7\r\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['nginx:1.14', 'redis:7']);
});
it('returns an empty list when stdout is empty', async () => {
mockComposeConfig('');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual([]);
});
it('rejects stack names that traverse outside the compose base', async () => {
await expect(
ComposeService.getInstance(1).listStackImages('../evil'),
).rejects.toThrow(/Invalid stack path/);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('rejects when docker compose exits non-zero', async () => {
mockSpawn.mockImplementation(() => {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
Promise.resolve().then(() => {
proc.stderr.emit('data', Buffer.from('compose file missing'));
proc.emit('close', 1);
});
return proc;
});
await expect(
ComposeService.getInstance(1).listStackImages('my-stack'),
).rejects.toThrow(/compose file missing/);
});
it('preserves image-ref ordering for deterministic downstream scans', async () => {
mockComposeConfig('redis:7\npostgres:15\nnginx:1.14\n');
const images = await ComposeService.getInstance(1).listStackImages('my-stack');
expect(images).toEqual(['redis:7', 'postgres:15', 'nginx:1.14']);
});
});