Files
sencho/backend/src/__tests__/git-change-plan.test.ts
T
Anso 3c4c057467 feat(git): classify managed-file changes before apply (#1832)
* feat(git): classify managed-file changes before apply

Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator.

* fix(git): contain stack-dir probes before filesystem access

The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync.

* fix(git): address managed-file change plan audit blockers

Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset.

* fix(git): unblock change-plan CI sinks and fifo test

Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync.

* fix(git): preserve unowned context files and align candidate validation

Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion.

* fix(git): contain live context and candidate env path sinks

Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink.

* fix(git): resolve live context walks from the compose root

Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base.

* fix(git): validate synced env removal against post-promotion files

A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names.

* docs(git): capture classified change-plan review screenshots

Replace the old Monaco pull-preview images with the classified operation list used by Apply.

* fix(git): treat invocation drift as reviewable, not a file conflict

A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses.
2026-08-14 09:53:31 -04:00

994 lines
45 KiB
TypeScript

/**
* Unit tests for GitChangePlanService: path-kind matrix, candidate invocation
* in the plan, live applied_deploy_spec not used as candidate invocation, and
* high-sensitivity paths absent from the public projection.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { BuildContextPlan, ComposeInputEntry, GitProjectManifest } from '../types/gitProjectManifest';
import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan';
let tmpDir: string;
let GitChangePlanService: typeof import('../services/GitChangePlanService').GitChangePlanService;
let GitProjectManifestService: typeof import('../services/GitProjectManifestService').GitProjectManifestService;
let buildCandidateComposeInvocation: typeof import('../utils/candidateComposeInvocation').buildCandidateComposeInvocation;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ GitChangePlanService } = await import('../services/GitChangePlanService'));
({ GitProjectManifestService } = await import('../services/GitProjectManifestService'));
({ buildCandidateComposeInvocation } = await import('../utils/candidateComposeInvocation'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
// Each test uses a unique stack name; no extra cleanup required.
});
function sha(content: string): string {
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
}
function stackDir(stackName: string): string {
const dir = path.join(process.env.COMPOSE_DIR!, stackName);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function writeStackFile(stackName: string, rel: string, content: string): void {
const abs = path.join(stackDir(stackName), rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
function managedEntry(partial: Partial<ComposeInputEntry> & { materializedPath: string; content: string }): ComposeInputEntry {
return {
sourcePath: partial.sourcePath ?? partial.materializedPath,
materializedPath: partial.materializedPath,
role: partial.role ?? 'compose-primary',
dependencyKind: partial.dependencyKind ?? 'explicit',
ownership: partial.ownership ?? 'managed',
provenance: partial.provenance ?? 'fetch',
sensitivity: partial.sensitivity ?? 'medium',
contentSha256: sha(partial.content),
sizeBytes: Buffer.byteLength(partial.content, 'utf8'),
state: partial.state ?? 'present',
deletionAuthority: partial.deletionAuthority ?? 'sencho',
note: partial.note ?? null,
};
}
function buildManifest(
stackName: string,
inputs: ComposeInputEntry[],
invocation: string[] = ['-f', 'compose.yaml', '-p', stackName],
contexts: BuildContextPlan[] = [],
): GitProjectManifest {
return GitProjectManifestService.getInstance().buildManifest({
stackName,
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
commitSha: 'abc123def456',
projectRoot: null,
composeFiles: ['compose.yaml'],
projectName: stackName,
invocation,
inputs,
refusals: [],
buildContexts: contexts,
bounds: {
maxFiles: 10_000,
maxBytes: 512 * 1024 * 1024,
maxContextBytes: 256 * 1024 * 1024,
maxPathDepth: 64,
maxFileBytes: 10 * 1024 * 1024,
},
priorManifest: null,
state: 'active',
});
}
describe('buildCandidateComposeInvocation', () => {
it('returns [] for a single-file selection with no context dir (auto-discovery)', () => {
expect(buildCandidateComposeInvocation({
stackName: 'web',
composePaths: ['compose.yaml'],
contextDir: null,
stackDir: '/app/compose/web',
syncEnv: false,
envContentPresent: false,
})).toEqual([]);
});
it('emits ordered -f / -p / --project-directory from the candidate selection, not a live spec', () => {
const stackDirAbs = path.resolve('/tmp/compose/web');
const args = buildCandidateComposeInvocation({
stackName: 'web',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: 'infra',
stackDir: stackDirAbs,
syncEnv: false,
envContentPresent: false,
});
expect(args).toEqual([
'-f', 'compose.yaml',
'-f', 'infra/prod.yml',
'-p', 'web',
'--project-directory', path.resolve(stackDirAbs, 'infra'),
]);
});
it('adds --env-file for sync-env when a context dir is set', () => {
const stackDirAbs = path.resolve('/tmp/compose/web');
const args = buildCandidateComposeInvocation({
stackName: 'web',
composePaths: ['infra/compose.yaml'],
contextDir: 'infra',
stackDir: stackDirAbs,
syncEnv: true,
envContentPresent: true,
});
expect(args).toEqual([
'-f', 'compose.yaml',
'-p', 'web',
'--project-directory', path.resolve(stackDirAbs, 'infra'),
'--env-file', path.resolve(stackDirAbs, '.env'),
]);
});
it('adds --env-file for a context-dir stack when root .env is already present', () => {
const stackDirAbs = path.resolve('/tmp/compose/web');
const args = buildCandidateComposeInvocation({
stackName: 'web',
composePaths: ['infra/compose.yaml'],
contextDir: 'infra',
stackDir: stackDirAbs,
syncEnv: false,
envContentPresent: false,
rootEnvFilePresent: true,
});
expect(args).toContain('--env-file');
expect(args).toContain(path.resolve(stackDirAbs, '.env'));
});
it('does not keep --env-file when sync-env omits the candidate .env', () => {
const stackDirAbs = path.resolve('/tmp/compose/web');
const args = buildCandidateComposeInvocation({
stackName: 'web',
composePaths: ['infra/compose.yaml'],
contextDir: 'infra',
stackDir: stackDirAbs,
syncEnv: true,
envContentPresent: false,
rootEnvFilePresent: true,
});
expect(args).not.toContain('--env-file');
expect(args).not.toContain(path.resolve(stackDirAbs, '.env'));
});
it('does not add --env-file for a single-file selection (Compose auto-loads .env)', () => {
const stackDirAbs = path.resolve('/tmp/compose/web');
expect(buildCandidateComposeInvocation({
stackName: 'web',
composePaths: ['compose.yaml'],
contextDir: null,
stackDir: stackDirAbs,
syncEnv: true,
envContentPresent: true,
})).toEqual([]);
});
});
describe('GitChangePlanService.build', () => {
it('classifies unmodified matching files as unchanged and does not block', async () => {
const stack = 'plan-unchanged';
const content = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', content);
const entry = managedEntry({ materializedPath: 'compose.yaml', content });
const prior = buildManifest(stack, [entry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [entry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(false);
expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('unchanged');
expect(plan.counts.unchanged).toBe(1);
});
it('classifies a live hash mismatch as local-modified and blocks', async () => {
const stack = 'plan-local-mod';
writeStackFile(stack, 'compose.yaml', 'services:\n web:\n image: nginx:local\n');
const priorContent = 'services:\n web:\n image: nginx\n';
const candidateContent = 'services:\n web:\n image: nginx:git\n';
const priorEntry = managedEntry({ materializedPath: 'compose.yaml', content: priorContent });
const candEntry = managedEntry({ materializedPath: 'compose.yaml', content: candidateContent });
const prior = buildManifest(stack, [priorEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [candEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('local-modified');
});
it('still blocks when live bytes match the candidate but not the last-applied hash', async () => {
const stack = 'plan-local-match-cand';
const priorContent = 'services:\n web:\n image: nginx\n';
const candidateContent = 'services:\n web:\n image: nginx:git\n';
writeStackFile(stack, 'compose.yaml', candidateContent);
const priorEntry = managedEntry({ materializedPath: 'compose.yaml', content: priorContent });
const candEntry = managedEntry({ materializedPath: 'compose.yaml', content: candidateContent });
const prior = buildManifest(stack, [priorEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [candEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
const localOp = plan.operations.find((o) => o.pathKey === 'compose.yaml');
expect(localOp?.op).toBe('local-modified');
expect(localOp?.liveHash).toBe(localOp?.candidateHash);
expect(localOp?.liveHash).not.toBe(localOp?.priorHash);
});
it('classifies a missing live file as local-missing and blocks', async () => {
const stack = 'plan-missing';
stackDir(stack);
const content = 'services:\n web:\n image: nginx\n';
const entry = managedEntry({ materializedPath: 'compose.yaml', content });
const prior = buildManifest(stack, [entry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [entry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('local-missing');
});
it('classifies a new candidate path over an unmanaged live file as unmanaged-collision', async () => {
const stack = 'plan-collision';
writeStackFile(stack, 'extra.yaml', 'services: {}\n');
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
const priorEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const extra = managedEntry({
materializedPath: 'extra.yaml',
content: 'services:\n db:\n image: postgres\n',
role: 'compose-additional',
});
const prior = buildManifest(stack, [priorEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [priorEntry, extra],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'extra.yaml')?.op).toBe('unmanaged-collision');
});
it('classifies a removed sencho-authority file as delete when live still matches', async () => {
const stack = 'plan-delete';
const compose = 'services:\n web:\n image: nginx\n';
const extra = 'services:\n db:\n image: postgres\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'extra.yaml', extra);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const extraEntry = managedEntry({
materializedPath: 'extra.yaml',
content: extra,
role: 'compose-additional',
});
const prior = buildManifest(stack, [composeEntry, extraEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(false);
expect(plan.operations.find((o) => o.pathKey === 'extra.yaml')?.op).toBe('delete');
});
it('pairs a same-hash delete+add as rename (presentation only)', async () => {
const stack = 'plan-rename';
const compose = 'services:\n web:\n image: nginx\n';
const shared = 'FOO=bar\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'old.env', shared);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const oldEnv = managedEntry({
materializedPath: 'old.env',
content: shared,
role: 'env',
dependencyKind: 'env_file',
});
const newEnv = managedEntry({
materializedPath: 'new.env',
content: shared,
role: 'env',
dependencyKind: 'env_file',
});
const prior = buildManifest(stack, [composeEntry, oldEnv]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry, newEnv],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
const rename = plan.operations.find((o) => o.op === 'rename');
expect(rename).toBeDefined();
expect(rename?.fromPath).toBe('old.env');
expect(rename?.pathKey).toBe('new.env');
expect(plan.operations.some((o) => o.op === 'delete' && o.pathKey === 'old.env')).toBe(false);
expect(plan.operations.some((o) => o.op === 'add' && o.pathKey === 'new.env')).toBe(false);
});
it('classifies a live directory at a file path as type-changed', async () => {
const stack = 'plan-type';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
fs.mkdirSync(path.join(stackDir(stack), 'config.yaml'));
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const configEntry = managedEntry({
materializedPath: 'config.yaml',
content: 'x: 1\n',
role: 'config',
dependencyKind: 'config',
});
const prior = buildManifest(stack, [composeEntry, configEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry, configEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'config.yaml')?.op).toBe('type-changed');
});
it('treats create mode as add even when live files already exist', async () => {
const stack = 'plan-create';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
const entry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'create',
priorManifest: null,
candidateInputs: [entry],
candidateBuildContexts: [],
candidateInvocation: [],
liveInvocation: [],
});
expect(plan.blocked).toBe(false);
expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('add');
});
it('classifies a live hash that drifted since review as local-modified', async () => {
const stack = 'plan-reviewed-drift';
const reviewed = 'services:\n web:\n image: nginx\n';
const drifted = 'services:\n web:\n image: nginx:local\n';
writeStackFile(stack, 'compose.yaml', drifted);
const entry = managedEntry({ materializedPath: 'compose.yaml', content: reviewed });
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: null,
candidateInputs: [entry],
candidateBuildContexts: [],
candidateInvocation: [],
liveInvocation: [],
legacyOwnedPaths: ['compose.yaml'],
reviewedLiveHashes: new Map([['compose.yaml', sha(reviewed)]]),
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'compose.yaml')?.op).toBe('local-modified');
});
it('records candidate invocation change as informational when live still matches prior', async () => {
const stack = 'plan-inv-info';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack]);
const prod = managedEntry({
materializedPath: 'prod.yaml',
content: 'services:\n web:\n restart: always\n',
role: 'compose-additional',
});
const candidateInv = ['-f', 'compose.yaml', '-f', 'prod.yaml', '-p', stack];
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry, prod],
candidateBuildContexts: [],
candidateInvocation: candidateInv,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(false);
expect(plan.invocationBlocked).toBe(false);
expect(plan.operations.some((o) => o.op === 'invocation')).toBe(true);
expect(plan.candidateInvocation).toEqual(candidateInv);
});
it('records live invocation divergence without a file-conflict block', async () => {
const stack = 'plan-inv-block';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
const entry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const priorInv = ['-f', 'compose.yaml', '-p', stack];
const prior = buildManifest(stack, [entry], priorInv);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [entry],
candidateBuildContexts: [],
candidateInvocation: priorInv,
liveInvocation: ['-f', 'compose.yaml', '-f', 'override.yaml', '-p', stack],
});
expect(plan.blocked).toBe(false);
expect(plan.invocationBlocked).toBe(true);
expect(plan.operations.some((o) => o.op === 'invocation')).toBe(true);
const pub = GitChangePlanService.getInstance().toPublic(plan);
expect(pub.blocked).toBe(false);
expect(pub.invocation.liveDiverged).toBe(true);
expect(pub.operations.some((o) => o.op === 'invocation')).toBe(true);
expect(pub.operations.find((o) => o.op === 'invocation')?.path).toBeNull();
});
it('redacts high-sensitivity paths from the public projection and omits hashes', async () => {
const stack = 'plan-secret';
const compose = 'services:\n web:\n image: nginx\n';
const secret = 'SUPERSECRET=1\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, '.env', secret);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const secretEntry = managedEntry({
materializedPath: '.env',
content: secret,
role: 'env',
dependencyKind: 'sync-env',
sensitivity: 'high',
});
const prior = buildManifest(stack, [composeEntry, secretEntry]);
const nextSecret = managedEntry({
materializedPath: '.env',
content: 'SUPERSECRET=2\n',
role: 'env',
dependencyKind: 'sync-env',
sensitivity: 'high',
});
// Live still matches prior, candidate changes the secret: modify, not blocked.
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry, nextSecret],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
const pub = GitChangePlanService.getInstance().toPublic(plan);
const serialized = JSON.stringify(pub);
expect(serialized).not.toContain('.env');
expect(serialized).not.toContain('SUPERSECRET');
expect(serialized).not.toContain(sha(secret));
expect(pub.operations.find((o) => o.op === 'modify')?.path).toBeNull();
expect(plan.fingerprint).toHaveLength(64);
expect(plan.schemaVersion).toBe(GIT_CHANGE_PLAN_SCHEMA_VERSION);
});
it('includes build-context files in the path universe', async () => {
const stack = 'plan-ctx';
const compose = 'services:\n web:\n image: nginx\n build: ./app\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const ctx: BuildContextPlan = {
repoPath: 'app',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }],
};
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [ctx],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.operations.find((o) => o.pathKey === 'app/Dockerfile')?.op).toBe('unchanged');
});
it('blocks locally added files inside a retained build context', async () => {
const stack = 'plan-ctx-local-add';
const compose = 'services:\n web:\n image: nginx\n build: ./app\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n');
writeStackFile(stack, 'app/extra.txt', 'local-only\n');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const ctx: BuildContextPlan = {
repoPath: 'app',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }],
};
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [ctx],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'app/extra.txt')?.op).toBe('unmanaged-collision');
});
it('classifies a prior-only upstream delete with an already-missing live file as local-missing', async () => {
const stack = 'plan-prior-missing';
const compose = 'services:\n web:\n image: nginx\n';
const extra = 'services:\n db:\n image: postgres\n';
writeStackFile(stack, 'compose.yaml', compose);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const extraEntry = managedEntry({
materializedPath: 'extra.yaml',
content: extra,
role: 'compose-additional',
});
const prior = buildManifest(stack, [composeEntry, extraEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'extra.yaml')?.op).toBe('local-missing');
});
it('binds configured project env files into the fingerprint and blocks reviewed drift', async () => {
const stack = 'plan-project-env';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'prod.env', 'FOO=1\n');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const prior = buildManifest(stack, [composeEntry]);
const { DatabaseService } = await import('../services/DatabaseService');
const { NodeRegistry } = await import('../services/NodeRegistry');
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
DatabaseService.getInstance().setStackProjectEnvFiles(nodeId, stack, ['prod.env']);
const stable = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
projectEnvFiles: ['prod.env'],
});
expect(stable.operations.find((o) => o.pathKey === 'prod.env')?.op).toBe('unchanged');
writeStackFile(stack, 'prod.env', 'FOO=2\n');
const drifted = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
projectEnvFiles: ['prod.env'],
reviewedLiveHashes: new Map([['prod.env', sha('FOO=1\n')]]),
});
expect(drifted.blocked).toBe(true);
expect(drifted.operations.find((o) => o.pathKey === 'prod.env')?.op).toBe('local-modified');
});
it('records ownership, provenance, and source revision on managed operations', async () => {
const stack = 'plan-metadata';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const prior = buildManifest(stack, [composeEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'deadbeef',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
const row = plan.operations.find((o) => o.pathKey === 'compose.yaml');
expect(row?.ownership).toBe('managed');
expect(row?.provenance).toBe('fetch');
expect(row?.sourceRevision).toBe('deadbeef');
expect(row?.reason).toBeTruthy();
expect(plan.operations.every((o) => o.ownership && o.provenance && o.sourceRevision && o.reason)).toBe(true);
});
it.runIf(process.platform !== 'win32')('classifies fifo nodes as type-changed without reading them', async () => {
const stack = 'plan-fifo';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
const fifoPath = path.join(stackDir(stack), 'pipe.fifo');
const created = spawnSync('mkfifo', [fifoPath], { stdio: 'ignore' });
expect(created.status).toBe(0);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const fifoEntry = managedEntry({
materializedPath: 'pipe.fifo',
content: 'ignored',
role: 'config',
dependencyKind: 'config',
});
const prior = buildManifest(stack, [composeEntry, fifoEntry]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry, fifoEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'pipe.fifo')?.op).toBe('type-changed');
});
it('blocks a locally added file inside a removed build context', async () => {
const stack = 'plan-removed-ctx-extra';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n');
writeStackFile(stack, 'app/notes.txt', 'keep-me\n');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const ctx: BuildContextPlan = {
repoPath: 'app',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }],
};
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'app/notes.txt')?.op).toBe('unmanaged-collision');
expect(fs.readFileSync(path.join(stackDir(stack), 'app', 'notes.txt'), 'utf8')).toBe('keep-me\n');
});
it('classifies a clean removed context as delete of owned files only', async () => {
const stack = 'plan-removed-ctx-clean';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const ctx: BuildContextPlan = {
repoPath: 'app',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }],
};
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(false);
expect(plan.operations.find((o) => o.pathKey === 'app/Dockerfile')?.op).toBe('delete');
});
it('redacts a secret-bearing locally added context file from the public plan', async () => {
const stack = 'plan-ctx-secret-extra';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n');
writeStackFile(stack, 'app/.env', 'TOKEN=supersecret\n');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const ctx: BuildContextPlan = {
repoPath: 'app',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }],
};
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [ctx],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
const extra = plan.operations.find((o) => o.pathKey === 'app/.env');
expect(extra?.op).toBe('unmanaged-collision');
expect(extra?.sensitivity).toBe('high');
const pub = GitChangePlanService.getInstance().toPublic(plan);
expect(JSON.stringify(pub)).not.toContain('.env');
expect(JSON.stringify(pub)).not.toContain('TOKEN');
});
it('redacts .env.local and .env.production context extras from the public plan', async () => {
const stack = 'plan-ctx-env-dot-names';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, 'app/Dockerfile', 'FROM alpine\n');
writeStackFile(stack, 'app/.env.local', 'TOKEN=local\n');
writeStackFile(stack, 'app/.env.production', 'TOKEN=prod\n');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const ctx: BuildContextPlan = {
repoPath: 'app',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }],
};
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [ctx],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
const local = plan.operations.find((o) => o.pathKey === 'app/.env.local');
const prod = plan.operations.find((o) => o.pathKey === 'app/.env.production');
expect(local?.op).toBe('unmanaged-collision');
expect(local?.sensitivity).toBe('high');
expect(prod?.op).toBe('unmanaged-collision');
expect(prod?.sensitivity).toBe('high');
const pub = GitChangePlanService.getInstance().toPublic(plan);
const collisions = pub.operations.filter((o) => o.op === 'unmanaged-collision');
expect(collisions).toHaveLength(2);
expect(collisions.every((o) => o.path === null)).toBe(true);
expect(JSON.stringify(pub)).not.toContain('.env.local');
expect(JSON.stringify(pub)).not.toContain('.env.production');
expect(JSON.stringify(pub)).not.toContain('TOKEN');
});
it('records an invocation change when a synced .env disappears from the candidate', async () => {
const stack = 'plan-sync-env-removed';
const compose = 'services:\n web:\n image: nginx\n';
const env = 'TAG=live\n';
writeStackFile(stack, 'compose.yaml', compose);
writeStackFile(stack, '.env', env);
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const envEntry = managedEntry({
materializedPath: '.env',
content: env,
role: 'env',
dependencyKind: 'sync-env',
sensitivity: 'high',
});
const stackDirAbs = path.resolve(stackDir(stack));
const invOpts = {
stackName: stack,
composePaths: ['app/compose.yaml'],
contextDir: 'app',
stackDir: stackDirAbs,
syncEnv: true,
};
const priorInv = buildCandidateComposeInvocation({ ...invOpts, envContentPresent: true });
const candidateInv = buildCandidateComposeInvocation({
...invOpts,
envContentPresent: false,
rootEnvFilePresent: true,
});
expect(priorInv).toContain('--env-file');
expect(candidateInv).not.toContain('--env-file');
const prior = buildManifest(stack, [composeEntry, envEntry], priorInv);
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [],
candidateInvocation: candidateInv,
liveInvocation: priorInv,
});
expect(plan.blocked).toBe(false);
expect(plan.operations.find((o) => o.pathKey === '.env')?.op).toBe('delete');
expect(plan.operations.find((o) => o.op === 'invocation')).toBeTruthy();
expect(plan.candidateInvocation).toEqual(candidateInv);
expect(plan.candidateInvocation).not.toContain('--env-file');
});
it('changes the fingerprint when rename source, ownership, sensitivity, or reason changes', () => {
const fingerprintOf = (overrides: Record<string, unknown>): string => {
const svc = GitChangePlanService.getInstance() as unknown as {
fingerprint: (input: {
commitSha: string;
priorManifestVersion: number | null;
priorAppliedDir: string | null;
operations: unknown[];
}) => string;
};
const base = {
pathKey: 'compose.yaml',
op: 'modify',
role: 'compose-primary',
deletionAuthority: 'sencho',
priorHash: 'aa',
candidateHash: 'bb',
liveHash: 'aa',
sensitivity: 'medium',
ownership: 'managed',
provenance: 'fetch',
sourceRevision: 'deadbeef',
reason: 'candidate content differs from prior',
};
return svc.fingerprint({
commitSha: 'deadbeef',
priorManifestVersion: 1,
priorAppliedDir: 'generations/applied',
operations: [{ ...base, ...overrides }],
});
};
const base = fingerprintOf({});
expect(fingerprintOf({ fromPath: 'old.yaml' })).not.toBe(base);
expect(fingerprintOf({ ownership: 'unmanaged' })).not.toBe(base);
expect(fingerprintOf({ sensitivity: 'high' })).not.toBe(base);
expect(fingerprintOf({ reason: 'live hash differs from prior managed hash' })).not.toBe(base);
expect(fingerprintOf({ provenance: 'adopted' })).not.toBe(base);
});
it.runIf(process.platform !== 'win32')('blocks a context-root symlink without enumerating the target', async () => {
const stack = 'plan-ctx-root-symlink';
const compose = 'services:\n web:\n image: nginx\n';
writeStackFile(stack, 'compose.yaml', compose);
const outside = path.join(process.env.COMPOSE_DIR!, '..', 'outside-ctx-root');
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, 'secret.txt'), 'should-not-be-read\n');
const appDir = path.join(stackDir(stack), 'app');
fs.symlinkSync(outside, appDir, 'dir');
const composeEntry = managedEntry({ materializedPath: 'compose.yaml', content: compose });
const ctx: BuildContextPlan = {
repoPath: 'app',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: sha('FROM alpine\n'), sizeBytes: 12 }],
};
const prior = buildManifest(stack, [composeEntry], ['-f', 'compose.yaml', '-p', stack], [ctx]);
const hashSpy = vi.spyOn(GitProjectManifestService.getInstance(), 'hashStackFile');
try {
const plan = await GitChangePlanService.getInstance().build({
stackName: stack,
commitSha: 'cafebabe',
mode: 'update',
priorManifest: prior,
candidateInputs: [composeEntry],
candidateBuildContexts: [ctx],
candidateInvocation: prior.project.invocation,
liveInvocation: prior.project.invocation,
});
expect(plan.blocked).toBe(true);
expect(plan.operations.find((o) => o.pathKey === 'app')?.op).toBe('type-changed');
expect(plan.operations.some((o) => o.pathKey.includes('secret.txt'))).toBe(false);
expect(JSON.stringify(plan.operations)).not.toContain('outside-ctx-root');
const hashedOutside = hashSpy.mock.calls.some((c) => String(c[1]).includes('secret.txt') || String(c[1]).includes('outside'));
expect(hashedOutside).toBe(false);
} finally {
hashSpy.mockRestore();
fs.rmSync(outside, { recursive: true, force: true });
}
});
});