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.
This commit is contained in:
Anso
2026-08-14 09:53:31 -04:00
committed by GitHub
parent 4c93947004
commit 3c4c057467
38 changed files with 4877 additions and 673 deletions
@@ -228,3 +228,136 @@ describe('authoredComposeEnvFileArgs', () => {
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/);
});
});
@@ -429,3 +429,79 @@ describe('drift route (GET read-only, POST recheck persists)', () => {
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(1);
});
});
describe('DriftLedgerService managed-path conflicts', () => {
const STACK = 'gitpath';
beforeEach(() => clearLedger(STACK));
it('upserts without resolving and keeps the original detected_at', () => {
const ledger = DriftLedgerService.getInstance();
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const first = db().getOpenDriftFindings(nodeId, STACK);
expect(first).toHaveLength(1);
expect(first[0].finding_type).toBe('managed-path-conflict');
expect(first[0].message).toBe('compose-primary local-modified');
const detectedAt = first[0].detected_at;
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const second = db().getOpenDriftFindings(nodeId, STACK);
expect(second).toHaveLength(1);
expect(second[0].detected_at).toBe(detectedAt);
expect(second[0].id).toBe(first[0].id);
});
it('redacts high-sensitivity paths in the stored message', () => {
DriftLedgerService.getInstance().upsertManagedPathConflicts(nodeId, STACK, [
{ path: '.env', op: 'local-modified', role: 'env', sensitivity: 'high' },
]);
const open = db().getOpenDriftFindings(nodeId, STACK);
expect(open[0].message).toBe('secret-bearing managed path (local-modified)');
expect(open[0].service).not.toContain('.env');
});
it('does not resolve a git finding during spatial reconcile', () => {
const ledger = DriftLedgerService.getInstance();
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const res = ledger.reconcile(nodeId, STACK, reportWith([], { stack: STACK, status: 'in-sync' }));
expect(res.resolved).toBe(0);
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(1);
expect(db().getOpenDriftFindings(nodeId, STACK)[0].finding_type).toBe('managed-path-conflict');
});
it('resolves git findings only through resolveManagedPathConflicts', () => {
const ledger = DriftLedgerService.getInstance();
ledger.upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
ledger.resolveManagedPathConflicts(nodeId, STACK);
expect(db().getOpenDriftFindings(nodeId, STACK)).toHaveLength(0);
});
it('GET drift redacts the opaque service key for managed-path conflicts', async () => {
const stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:1.27\n');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
} as unknown as DockerController);
DriftLedgerService.getInstance().upsertManagedPathConflicts(nodeId, STACK, [
{ path: 'compose.yaml', op: 'local-modified', role: 'compose-primary', sensitivity: 'medium' },
]);
const stored = db().getOpenDriftFindings(nodeId, STACK)[0];
expect(stored.service.length).toBeGreaterThan(0);
const res = await request(app).get(`/api/stacks/${STACK}/drift`).set('Authorization', authHeader);
expect(res.status).toBe(200);
const gitRow = res.body.ledger.find((r: { kind: string }) => r.kind === 'managed-path-conflict');
expect(gitRow).toBeDefined();
expect(gitRow.service).toBe('');
expect(JSON.stringify(res.body)).not.toContain(stored.service);
fs.rmSync(stackDir, { recursive: true, force: true });
});
});
@@ -13,9 +13,8 @@ import path from 'path';
import os from 'os';
import { isValidRelativeStackPath } from '../utils/validation';
// On Windows, fs.unlink on a directory returns EPERM rather than EISDIR.
// The deleteStackPath empty-dir and NOT_EMPTY paths rely on EISDIR (Linux/macOS).
// Skip those specific cases on Windows.
// deleteStackPath rmdirs directories directly, so empty-dir and NOT_EMPTY
// cases run on every platform.
const isWindows = process.platform === 'win32';
// Mutable state the mocked NodeRegistry reads. Each beforeEach updates it
@@ -458,7 +457,7 @@ describe('FileSystemService stack methods', () => {
await expect(fs.access(path.join(stackDir, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('deletes an empty directory (Linux/macOS only: Windows unlink returns EPERM)', async () => {
it('deletes an empty directory', async () => {
await fs.mkdir(path.join(stackDir, 'emptydir'));
const service = FileSystemService.getInstance();
@@ -467,7 +466,7 @@ describe('FileSystemService stack methods', () => {
await expect(fs.access(path.join(stackDir, 'emptydir'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('throws NOT_EMPTY for non-empty directory without recursive flag (Linux/macOS only)', async () => {
it('throws NOT_EMPTY for a non-empty directory without the recursive flag', async () => {
await fs.mkdir(path.join(stackDir, 'nonempty'));
await fs.writeFile(path.join(stackDir, 'nonempty', 'child.txt'), '');
@@ -0,0 +1,993 @@
/**
* 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 });
}
});
});
@@ -1,11 +1,14 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
import { FileSystemService } from '../services/FileSystemService';
import { GitProjectManifestService, PROMOTION_MARKER, CANDIDATE_COMPLETE_MARKER } from '../services/GitProjectManifestService';
import { GitProjectManifestService, PROMOTION_MARKER, CANDIDATE_COMPLETE_MARKER, PromoteGenerationError } from '../services/GitProjectManifestService';
import { NodeRegistry } from '../services/NodeRegistry';
import { authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
import type { ComposeInputEntry, GitProjectManifest, ManifestBounds } from '../types/gitProjectManifest';
const BOUNDS: ManifestBounds = {
@@ -434,7 +437,11 @@ describe('promoteGeneration', () => {
candidateRelPath: candidateRel,
manifest: incoming,
priorManifest: prior,
})).rejects.toThrow(/Case-only managed path changes/);
})).rejects.toSatisfy((err: unknown) =>
err instanceof PromoteGenerationError
&& err.phase === 'pre_mutation'
&& /Case-only managed path changes/.test(err.message),
);
expect(readStackFile(stackName, 'Config.yml')).toBe('PRIOR\n');
expect(fs.existsSync(path.join(tmpDir, 'git-managed', '1', stackName, PROMOTION_MARKER))).toBe(false);
});
@@ -654,6 +661,71 @@ describe('promoteGeneration', () => {
});
expect(readStackFile(stackName, '.env')).toBe('NEW=1\n');
});
it('deletes a previously managed synced .env when the next generation omits it', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'promote-sync-env-removed';
writeStackFile(stackName, 'compose.yaml', 'v1\n');
writeStackFile(stackName, '.env', 'SYNC=1\n');
seedGitSource(stackName);
DatabaseService.getInstance().setGitSourceAppliedSpec(stackName, {
files: ['compose.yaml'],
contextDir: 'app',
});
const syncEnvEntry: ComposeInputEntry = {
sourcePath: null,
materializedPath: '.env',
role: 'env',
dependencyKind: 'sync-env',
ownership: 'managed',
provenance: 'fetch',
sensitivity: 'high',
contentSha256: crypto.createHash('sha256').update('SYNC=1\n').digest('hex'),
sizeBytes: Buffer.byteLength('SYNC=1\n'),
state: 'present',
deletionAuthority: 'sencho',
note: null,
};
const prior = buildManifest(stackName, [
managedEntry({ materializedPath: 'compose.yaml' }),
syncEnvEntry,
]);
const priorRel = 'generations/applied-prior';
const priorAbs = path.join(tmpDir, 'git-managed', '1', stackName, priorRel);
fs.mkdirSync(priorAbs, { recursive: true });
fs.writeFileSync(path.join(priorAbs, 'compose.yaml'), 'v1\n');
fs.writeFileSync(path.join(priorAbs, '.env'), 'SYNC=1\n');
prior.generation.appliedDir = priorRel;
await svc.writeManifest(stackName, prior);
const incoming = buildManifest(stackName, [
managedEntry({ materializedPath: 'compose.yaml' }),
], prior);
const clone = makeClone({ 'compose.yaml': 'v2\n' });
const candidateRel = await svc.buildCandidate(
stackName,
'sha-env-gone',
clone,
[{ srcRel: 'compose.yaml', destRel: 'compose.yaml' }],
[],
BOUNDS,
);
await svc.promoteGeneration(stackName, {
sha: 'sha-env-gone',
candidateRelPath: candidateRel,
manifest: incoming,
priorManifest: prior,
});
expect(fs.existsSync(path.join(stackDir(stackName), '.env'))).toBe(false);
expect(readStackFile(stackName, 'compose.yaml')).toBe('v2\n');
const deployArgs = await authoredComposeEnvFileArgs(
stackName,
NodeRegistry.getInstance().getDefaultNodeId(),
);
expect(deployArgs).toEqual([]);
});
});
describe('sweepManagedArea (crash recovery)', () => {
@@ -1483,4 +1555,233 @@ describe('build-context file-level ownership (audit round 2 C-2)', () => {
const divergedAfter = await svc.verifyContextOnDisk(stackName, manifest2.buildContexts[0]);
expect(divergedAfter.some((p) => p.includes('keep.txt'))).toBe(true);
});
it('preserves an unowned file when a non-root context is removed', async () => {
const svc = GitProjectManifestService.getInstance();
const { ComposeInputDiscoveryService } = await import('../services/ComposeInputDiscoveryService');
const discovery = ComposeInputDiscoveryService.getInstance();
const stackName = 'context-removed-unowned';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
const clone1 = makeClone({
'compose.yaml': 'services:\n web:\n build:\n context: web\n',
'web/keep.txt': 'keep\n',
});
const inv1 = await discovery.discoverFromClone({ cloneDir: clone1, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
const managed1 = inv1.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null);
const manifest1 = buildManifest(stackName, managed1, null, inv1.buildContexts);
const fileList1 = managed1.filter((i) => i.dependencyKind !== 'build-context');
const cand1 = await svc.buildCandidate(stackName, 'rev1', clone1, fileList1.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv1.contextCopyPlans, BOUNDS);
await svc.promoteGeneration(stackName, { sha: 'rev1', candidateRelPath: cand1, manifest: manifest1, priorManifest: null, adoptExistingMaterializedPaths: 'all' });
fs.writeFileSync(path.join(stackDir(stackName), 'web', 'notes.txt'), 'local\n');
const clone2 = makeClone({
'compose.yaml': 'services:\n web:\n image: nginx\n',
});
const inv2 = await discovery.discoverFromClone({ cloneDir: clone2, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
const managed2 = inv2.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null);
const manifest2 = buildManifest(stackName, managed2, manifest1, inv2.buildContexts);
const fileList2 = managed2.filter((i) => i.dependencyKind !== 'build-context');
const cand2 = await svc.buildCandidate(stackName, 'rev2', clone2, fileList2.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv2.contextCopyPlans, BOUNDS);
await svc.promoteGeneration(stackName, { sha: 'rev2', candidateRelPath: cand2, manifest: manifest2, priorManifest: manifest1 });
expect(fs.existsSync(path.join(stackDir(stackName), 'web', 'keep.txt'))).toBe(false);
expect(fs.readFileSync(path.join(stackDir(stackName), 'web', 'notes.txt'), 'utf8')).toBe('local\n');
});
it('removes a clean non-root context directory after owned files are gone', async () => {
const svc = GitProjectManifestService.getInstance();
const { ComposeInputDiscoveryService } = await import('../services/ComposeInputDiscoveryService');
const discovery = ComposeInputDiscoveryService.getInstance();
const stackName = 'context-removed-clean';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
const clone1 = makeClone({
'compose.yaml': 'services:\n web:\n build:\n context: web\n',
'web/keep.txt': 'keep\n',
});
const inv1 = await discovery.discoverFromClone({ cloneDir: clone1, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
const managed1 = inv1.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null);
const manifest1 = buildManifest(stackName, managed1, null, inv1.buildContexts);
const fileList1 = managed1.filter((i) => i.dependencyKind !== 'build-context');
const cand1 = await svc.buildCandidate(stackName, 'rev1', clone1, fileList1.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv1.contextCopyPlans, BOUNDS);
await svc.promoteGeneration(stackName, { sha: 'rev1', candidateRelPath: cand1, manifest: manifest1, priorManifest: null, adoptExistingMaterializedPaths: 'all' });
const clone2 = makeClone({
'compose.yaml': 'services:\n web:\n image: nginx\n',
});
const inv2 = await discovery.discoverFromClone({ cloneDir: clone2, composePaths: ['compose.yaml'], contextDir: null, bounds: BOUNDS });
const managed2 = inv2.inputs.filter((i) => i.ownership === 'managed' && i.materializedPath !== null);
const manifest2 = buildManifest(stackName, managed2, manifest1, inv2.buildContexts);
const fileList2 = managed2.filter((i) => i.dependencyKind !== 'build-context');
const cand2 = await svc.buildCandidate(stackName, 'rev2', clone2, fileList2.map((i) => ({ srcRel: i.sourcePath!, destRel: i.materializedPath! })), inv2.contextCopyPlans, BOUNDS);
await svc.promoteGeneration(stackName, { sha: 'rev2', candidateRelPath: cand2, manifest: manifest2, priorManifest: manifest1 });
expect(fs.existsSync(path.join(stackDir(stackName), 'web'))).toBe(false);
});
it('removes root-context managed files individually and leaves unowned stack files', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'context-root-removed';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
writeStackFile(stackName, 'Dockerfile', 'FROM alpine\n');
writeStackFile(stackName, 'local-notes.txt', 'keep\n');
const composeEntry = {
sourcePath: 'compose.yaml',
materializedPath: 'compose.yaml',
role: 'compose-primary' as const,
dependencyKind: 'explicit' as const,
ownership: 'managed' as const,
provenance: 'fetch' as const,
sensitivity: 'medium' as const,
contentSha256: crypto.createHash('sha256').update('services: {}\n').digest('hex'),
sizeBytes: 12,
state: 'present' as const,
deletionAuthority: 'sencho' as const,
note: null,
};
const priorCtx = {
repoPath: '',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'Dockerfile', sha256: crypto.createHash('sha256').update('FROM alpine\n').digest('hex'), sizeBytes: 12 }],
};
const prior = buildManifest(stackName, [composeEntry], null, [priorCtx]);
const clone = makeClone({ 'compose.yaml': 'services:\n web:\n image: nginx\n' });
const nextCompose = {
...composeEntry,
contentSha256: crypto.createHash('sha256').update('services:\n web:\n image: nginx\n').digest('hex'),
sizeBytes: Buffer.byteLength('services:\n web:\n image: nginx\n'),
};
const next = buildManifest(stackName, [nextCompose], prior, []);
const cand = await svc.buildCandidate(stackName, 'root-rm', clone, [{ srcRel: 'compose.yaml', destRel: 'compose.yaml' }], [], BOUNDS);
await svc.promoteGeneration(stackName, { sha: 'root-rm', candidateRelPath: cand, manifest: next, priorManifest: prior });
expect(fs.existsSync(path.join(stackDir(stackName), 'Dockerfile'))).toBe(false);
expect(fs.readFileSync(path.join(stackDir(stackName), 'local-notes.txt'), 'utf8')).toBe('keep\n');
expect(fs.existsSync(path.join(stackDir(stackName), 'compose.yaml'))).toBe(true);
});
it('fails closed when live context scanning exceeds the file bound', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'context-scan-bound';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
writeStackFile(stackName, 'web/a.txt', 'a\n');
writeStackFile(stackName, 'web/b.txt', 'b\n');
writeStackFile(stackName, 'web/c.txt', 'c\n');
const ctx = {
repoPath: 'web',
dockerfile: null,
contextBytes: 0,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'a.txt', sha256: 'x', sizeBytes: 1 }],
};
const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxFiles: 1 });
expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true);
});
it('fails closed when live context scanning exceeds the path-depth bound', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'context-scan-depth';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
writeStackFile(stackName, 'web/a/b/c.txt', 'deep\n');
const ctx = {
repoPath: 'web',
dockerfile: null,
contextBytes: 0,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'a/b/c.txt', sha256: 'x', sizeBytes: 1 }],
};
const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxPathDepth: 1 });
expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true);
});
it('fails closed when a live context file exceeds maxFileBytes before hashing', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'context-scan-file-bytes';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
writeStackFile(stackName, 'web/big.txt', 'abcdefghij\n');
const ctx = {
repoPath: 'web',
dockerfile: null,
contextBytes: 0,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'big.txt', sha256: 'x', sizeBytes: 1 }],
};
const hashSpy = vi.spyOn(svc, 'hashStackFile');
try {
const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxFileBytes: 4 });
expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true);
expect(hashSpy).not.toHaveBeenCalled();
} finally {
hashSpy.mockRestore();
}
});
it('fails closed when live context scanning exceeds the directory-entry bound', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'context-scan-empty-dirs';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
for (let i = 0; i < 8; i++) {
fs.mkdirSync(path.join(stackDir(stackName), 'web', `d${i}`), { recursive: true });
}
const ctx = {
repoPath: 'web',
dockerfile: null,
contextBytes: 0,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'keep.txt', sha256: 'x', sizeBytes: 1 }],
};
const diverged = await svc.verifyContextOnDisk(stackName, ctx, undefined, { ...BOUNDS, maxFiles: 3 });
expect(diverged.some((p) => p.includes('scan limit exceeded'))).toBe(true);
});
it.runIf(process.platform !== 'win32')('does not follow a nested context symlink to inspect owned descendants', async () => {
const svc = GitProjectManifestService.getInstance();
const stackName = 'context-nested-symlink';
writeStackFile(stackName, 'compose.yaml', 'services: {}\n');
const outside = path.join(process.env.COMPOSE_DIR!, '..', 'outside-nested-symlink');
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, 'Dockerfile'), 'FROM alpine\n');
fs.writeFileSync(path.join(outside, 'secret.txt'), 'should-not-be-read\n');
const webDir = path.join(stackDir(stackName), 'web');
fs.mkdirSync(webDir, { recursive: true });
fs.symlinkSync(outside, path.join(webDir, 'nested'), 'dir');
const ctx = {
repoPath: 'web',
dockerfile: 'Dockerfile',
contextBytes: 12,
ignoredCount: 0,
dockerignoreApplied: false,
excludedFromCopy: false,
note: null,
files: [{ path: 'nested/Dockerfile', sha256: 'x', sizeBytes: 12 }],
};
const hashSpy = vi.spyOn(svc, 'hashStackFile');
const observeSpy = vi.spyOn(FileSystemService.getInstance(), 'observeStackPath');
try {
const diverged = await svc.verifyContextOnDisk(stackName, ctx);
expect(diverged.some((p) => p.includes('nested') && p.includes('symbolic link'))).toBe(true);
expect(hashSpy.mock.calls.some((c) => String(c[1]).includes('secret') || String(c[1]).includes('outside'))).toBe(false);
expect(observeSpy.mock.calls.some((c) => {
const rel = String(c[1]);
return rel.includes('nested/Dockerfile') || rel.includes('secret.txt');
})).toBe(false);
} finally {
hashSpy.mockRestore();
observeSpy.mockRestore();
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
@@ -3,6 +3,7 @@
* compensateWithCandidate is not called.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan';
const mockCaptureCandidate = vi.fn();
const mockAbandon = vi.fn();
@@ -76,36 +77,46 @@ vi.mock('../services/NodeRegistry', () => ({
}));
const mockPromoteGeneration = vi.fn().mockResolvedValue(undefined);
vi.mock('../services/GitProjectManifestService', () => ({
GitProjectManifestService: {
getInstance: () => ({
readManifest: vi.fn().mockResolvedValue(null),
buildManifest: vi.fn().mockReturnValue({
manifestVersion: 1,
state: 'active',
inputs: [],
refusals: [],
generation: { candidateDir: 'c', appliedDir: 'a', previousDir: null },
vi.mock('../services/GitProjectManifestService', async () => {
const actual = await vi.importActual<typeof import('../services/GitProjectManifestService')>(
'../services/GitProjectManifestService',
);
return {
...actual,
GitProjectManifestService: {
getInstance: () => ({
readManifest: vi.fn().mockResolvedValue(null),
buildManifest: vi.fn().mockReturnValue({
manifestVersion: 1,
state: 'active',
inputs: [],
refusals: [],
generation: { candidateDir: 'c', appliedDir: 'a', previousDir: null },
}),
promoteGeneration: mockPromoteGeneration,
boundsConfig: vi.fn().mockReturnValue({}),
hashStackFile: vi.fn(),
verifyContextOnDisk: vi.fn().mockResolvedValue([]),
writeManifest: vi.fn(),
buildMigratedManifest: vi.fn(),
}),
promoteGeneration: mockPromoteGeneration,
boundsConfig: vi.fn().mockReturnValue({}),
hashStackFile: vi.fn(),
verifyContextOnDisk: vi.fn().mockResolvedValue([]),
writeManifest: vi.fn(),
buildMigratedManifest: vi.fn(),
}),
},
}));
},
};
});
vi.mock('../utils/authoredComposeArgs', () => ({
authoredComposeFileArgs: vi.fn().mockResolvedValue(['-f', 'compose.yaml']),
authoredComposeEnvFileArgs: vi.fn().mockResolvedValue([]),
candidateValidationEnvFileArgs: vi.fn().mockResolvedValue([]),
}));
const mockGetGitSource = vi.fn();
const mockMarkGitSourceApplied = vi.fn();
const mockSetGitSourceAppliedSpec = vi.fn();
const mockSetGitSourceManifestState = vi.fn();
const mockUpdateGitSourcePendingPlan = vi.fn();
const mockSetGitSourceLastPlan = vi.fn();
const mockAddNotificationHistory = vi.fn();
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
@@ -114,6 +125,19 @@ vi.mock('../services/DatabaseService', () => ({
markGitSourceApplied: mockMarkGitSourceApplied,
setGitSourceAppliedSpec: mockSetGitSourceAppliedSpec,
setGitSourceManifestState: mockSetGitSourceManifestState,
updateGitSourcePendingPlan: mockUpdateGitSourcePendingPlan,
setGitSourceLastPlan: mockSetGitSourceLastPlan,
addNotificationHistory: mockAddNotificationHistory,
getStackProjectEnvFiles: vi.fn().mockReturnValue([]),
}),
},
}));
vi.mock('../services/DriftLedgerService', () => ({
DriftLedgerService: {
getInstance: () => ({
upsertManagedPathConflicts: vi.fn(),
resolveManagedPathConflicts: vi.fn(),
}),
},
}));
@@ -162,7 +186,7 @@ describe('git-source apply recovery (R1)', () => {
branch: 'main',
pending_commit_sha: 'abc1234deadbeef',
pending_compose_content: JSON.stringify({
v: 3,
v: 4,
files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' },
contextDir: null,
candidateRelPath: 'generations/cand',
@@ -171,8 +195,12 @@ describe('git-source apply recovery (R1)', () => {
refusals: [],
buildContexts: [],
},
planFingerprint: 'fp-test',
planSchemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION,
operationId: 'op-aaaaaaaa',
}),
pending_env_content: null,
pending_plan_blocked: false,
sync_env: false,
compose_paths: ['compose.yaml'],
context_dir: null,
@@ -181,52 +209,57 @@ describe('git-source apply recovery (R1)', () => {
});
});
it('keeps applied=true and generation current without compensate when deploy fails', async () => {
const { GitSourceService } = await import('../services/GitSourceService');
// Avoid withStackLock contention by calling applyLocked through apply
// after stubbing the lock if present.
const svc = GitSourceService.getInstance();
const withLock = vi.spyOn(
svc as unknown as { withStackLock: (name: string, fn: () => Promise<unknown>) => Promise<unknown> },
'withStackLock',
);
withLock.mockImplementation(async (_name, fn) => fn());
const CLEAN_PLAN = {
schemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION,
fingerprint: 'fp-test',
blocked: false,
invocationBlocked: false,
candidateInvocation: ['-f', 'compose.yaml', '-p', 'app'],
liveInvocation: ['-f', 'compose.yaml', '-p', 'app'],
priorInvocation: ['-f', 'compose.yaml', '-p', 'app'],
operations: [],
counts: {
add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0,
localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0,
},
};
// validateCandidate is used on the v3 path; stub it open.
vi.spyOn(
svc as unknown as {
validateCandidate: (...args: unknown[]) => Promise<{ ok: boolean }>;
},
'validateCandidate',
).mockResolvedValue({ ok: true });
vi.spyOn(
svc as unknown as {
decodePendingCompose: (raw: string) => unknown;
},
'decodePendingCompose',
).mockReturnValue({
files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' },
function stubApplyPath(svc: {
withStackLock: (name: string, fn: () => Promise<unknown>) => Promise<unknown>;
validateCandidate: (...args: unknown[]) => Promise<{ ok: boolean }>;
decodePendingCompose: (raw: string) => unknown;
deriveAppliedSpec: (...args: unknown[]) => unknown;
hashContent: (...args: unknown[]) => string;
computeChangePlan: (...args: unknown[]) => Promise<typeof CLEAN_PLAN>;
}) {
vi.spyOn(svc, 'withStackLock').mockImplementation(async (_name, fn) => fn());
vi.spyOn(svc, 'validateCandidate').mockResolvedValue({ ok: true });
vi.spyOn(svc, 'decodePendingCompose').mockReturnValue({
version: 4,
files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }],
contextDir: null,
candidateRelPath: 'generations/cand',
inventory: { inputs: [], refusals: [], buildContexts: [] },
planFingerprint: 'fp-test',
planSchemaVersion: GIT_CHANGE_PLAN_SCHEMA_VERSION,
operationId: 'op-aaaaaaaa',
reviewedLive: [],
});
vi.spyOn(svc, 'computeChangePlan').mockResolvedValue(CLEAN_PLAN);
vi.spyOn(svc, 'deriveAppliedSpec').mockReturnValue({ files: ['compose.yaml'], contextDir: null });
vi.spyOn(svc, 'hashContent').mockReturnValue('hash');
}
vi.spyOn(
svc as unknown as {
deriveAppliedSpec: (...args: unknown[]) => unknown;
},
'deriveAppliedSpec',
).mockReturnValue({ files: ['compose.yaml'], contextDir: null });
it('keeps applied=true and generation current without compensate when deploy fails', async () => {
const { GitSourceService } = await import('../services/GitSourceService');
const svc = GitSourceService.getInstance();
stubApplyPath(svc as never);
vi.spyOn(
svc as unknown as {
hashContent: (...args: unknown[]) => string;
},
'hashContent',
).mockReturnValue('hash');
const result = await svc.apply('app', 'abc1234deadbeef', { deploy: true, actor: 'tester' });
const result = await svc.apply('app', 'abc1234deadbeef', {
deploy: true,
actor: 'tester',
requirePlanFingerprint: false,
});
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
@@ -239,6 +272,7 @@ describe('git-source apply recovery (R1)', () => {
expect(mockHandoff).toHaveBeenCalled();
expect(mockCompensate).not.toHaveBeenCalled();
expect(mockAbandon).not.toHaveBeenCalled();
expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'applied');
});
it('refuses to promote when recovery capture fails', async () => {
@@ -247,37 +281,62 @@ describe('git-source apply recovery (R1)', () => {
const { GitSourceService, GitSourceError } = await import('../services/GitSourceService');
const svc = GitSourceService.getInstance();
vi.spyOn(
svc as unknown as { withStackLock: (name: string, fn: () => Promise<unknown>) => Promise<unknown> },
'withStackLock',
).mockImplementation(async (_name, fn) => fn());
vi.spyOn(
svc as unknown as { validateCandidate: (...args: unknown[]) => Promise<{ ok: boolean }> },
'validateCandidate',
).mockResolvedValue({ ok: true });
vi.spyOn(
svc as unknown as { decodePendingCompose: (raw: string) => unknown },
'decodePendingCompose',
).mockReturnValue({
files: { 'compose.yaml': 'services:\n web:\n image: nginx\n' },
contextDir: null,
candidateRelPath: 'generations/cand',
inventory: { inputs: [], refusals: [], buildContexts: [] },
});
vi.spyOn(
svc as unknown as { deriveAppliedSpec: (...args: unknown[]) => unknown },
'deriveAppliedSpec',
).mockReturnValue({ files: ['compose.yaml'], contextDir: null });
vi.spyOn(
svc as unknown as { hashContent: (...args: unknown[]) => string },
'hashContent',
).mockReturnValue('hash');
stubApplyPath(svc as never);
await expect(svc.apply('app', 'abc1234deadbeef', { deploy: true, actor: 'tester' })).rejects.toBeInstanceOf(GitSourceError);
await expect(svc.apply('app', 'abc1234deadbeef', {
deploy: true,
actor: 'tester',
requirePlanFingerprint: false,
})).rejects.toBeInstanceOf(GitSourceError);
expect(mockPromoteGeneration).not.toHaveBeenCalled();
expect(mockCaptureCandidate).toHaveBeenCalled();
expect(mockMarkGitSourceApplied).not.toHaveBeenCalled();
expect(mockHandoff).not.toHaveBeenCalled();
expect(mockAbandon).not.toHaveBeenCalled();
});
it('records git_apply_failed when promote fails before mutation', async () => {
const { PromoteGenerationError } = await import('../services/GitProjectManifestService');
mockPromoteGeneration.mockRejectedValueOnce(new PromoteGenerationError('pre_mutation', new Error('refused')));
const { GitSourceService, GitSourceError } = await import('../services/GitSourceService');
const svc = GitSourceService.getInstance();
stubApplyPath(svc as never);
await expect(svc.apply('app', 'abc1234deadbeef', { requirePlanFingerprint: false })).rejects.toBeInstanceOf(GitSourceError);
expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'failed');
expect(mockAddNotificationHistory).toHaveBeenCalledWith(
1,
expect.objectContaining({ category: 'git_apply_failed' }),
);
});
it('records git_apply_rolled_back when promote restore succeeds', async () => {
const { PromoteGenerationError } = await import('../services/GitProjectManifestService');
mockPromoteGeneration.mockRejectedValueOnce(new PromoteGenerationError('restored', new Error('write failed')));
const { GitSourceService, GitSourceError } = await import('../services/GitSourceService');
const svc = GitSourceService.getInstance();
stubApplyPath(svc as never);
await expect(svc.apply('app', 'abc1234deadbeef', { requirePlanFingerprint: false })).rejects.toBeInstanceOf(GitSourceError);
expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'rolled_back');
expect(mockAddNotificationHistory).toHaveBeenCalledWith(
1,
expect.objectContaining({ category: 'git_apply_rolled_back' }),
);
});
it('records git_apply_failed when restore itself fails', async () => {
const { PromoteGenerationError } = await import('../services/GitProjectManifestService');
mockPromoteGeneration.mockRejectedValueOnce(new PromoteGenerationError('recovery_required', new Error('restore failed')));
const { GitSourceService, GitSourceError } = await import('../services/GitSourceService');
const svc = GitSourceService.getInstance();
stubApplyPath(svc as never);
await expect(svc.apply('app', 'abc1234deadbeef', { requirePlanFingerprint: false })).rejects.toBeInstanceOf(GitSourceError);
expect(mockSetGitSourceLastPlan).toHaveBeenCalledWith('app', 'fp-test', 'failed');
expect(mockAddNotificationHistory).toHaveBeenCalledWith(
1,
expect.objectContaining({ category: 'git_apply_failed' }),
);
});
});
@@ -27,6 +27,17 @@ describe('gitSourceStatus', () => {
expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504);
});
it('maps PLAN_FINGERPRINT_REQUIRED to 400', () => {
expect(gitSourceStatus('PLAN_FINGERPRINT_REQUIRED')).toBe(400);
});
it('maps stale, blocked, legacy, and unavailable plans to 409', () => {
expect(gitSourceStatus('STALE_PLAN')).toBe(409);
expect(gitSourceStatus('PLAN_BLOCKED')).toBe(409);
expect(gitSourceStatus('LEGACY_PENDING')).toBe(409);
expect(gitSourceStatus('PLAN_UNAVAILABLE')).toBe(409);
});
it('maps unknown codes to 400', () => {
expect(gitSourceStatus('GIT_ERROR')).toBe(400);
});
@@ -70,4 +81,39 @@ describe('sendGitSourceError', () => {
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Git source operation failed' });
});
it('attaches plan extras on STALE_PLAN and PLAN_BLOCKED', () => {
const plan = { blocked: true, counts: {}, operations: [], invocation: { candidateChanged: false, liveDiverged: false } };
const stale = mockRes();
sendGitSourceError(stale, new GitSourceError('STALE_PLAN', 'stale', { plan: plan as never, planFingerprint: 'fp-new' }));
expect(stale.status).toHaveBeenCalledWith(409);
expect(stale.json).toHaveBeenCalledWith({
error: 'stale',
code: 'STALE_PLAN',
plan,
planFingerprint: 'fp-new',
});
const blocked = mockRes();
sendGitSourceError(blocked, new GitSourceError('PLAN_BLOCKED', 'blocked', { plan: plan as never, planFingerprint: 'fp-b' }));
expect(blocked.status).toHaveBeenCalledWith(409);
expect(blocked.json).toHaveBeenCalledWith({
error: 'blocked',
code: 'PLAN_BLOCKED',
plan,
planFingerprint: 'fp-b',
});
});
it('maps LEGACY_PENDING and PLAN_UNAVAILABLE to 409 without extras', () => {
const legacy = mockRes();
sendGitSourceError(legacy, new GitSourceError('LEGACY_PENDING', 'legacy'));
expect(legacy.status).toHaveBeenCalledWith(409);
expect(legacy.json).toHaveBeenCalledWith({ error: 'legacy', code: 'LEGACY_PENDING' });
const missing = mockRes();
sendGitSourceError(missing, new GitSourceError('PLAN_UNAVAILABLE', 'unavailable'));
expect(missing.status).toHaveBeenCalledWith(409);
expect(missing.json).toHaveBeenCalledWith({ error: 'unavailable', code: 'PLAN_UNAVAILABLE' });
});
});
@@ -19,7 +19,7 @@ import path from 'path';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { DatabaseService } from '../services/DatabaseService';
import { ComposeService } from '../services/ComposeService';
import { GitSourceService } from '../services/GitSourceService';
import { GitSourceService, GitSourceError } from '../services/GitSourceService';
// ── Hoisted mocks (must come before importing the app) ─────────────────
@@ -1033,6 +1033,16 @@ describe('stack_git_sources manifest cache columns', () => {
expect(row.manifest_generation).toBe('generations/applied-x');
});
it('migrateGitSourceChangePlan is idempotent', () => {
const db = DatabaseService.getInstance() as unknown as { migrateGitSourceChangePlan: () => void };
expect(() => {
db.migrateGitSourceChangePlan();
db.migrateGitSourceChangePlan();
}).not.toThrow();
const row = DatabaseService.getInstance().getGitSource('existing-stack');
expect(row === undefined || row.pending_plan_fingerprint === null || typeof row.pending_plan_fingerprint === 'string').toBe(true);
});
it('GET keeps flat manifest_state aligned with the healed summary', async () => {
const composeDir = process.env.COMPOSE_DIR!;
fs.mkdirSync(path.join(composeDir, 'stale-manifest-get'), { recursive: true });
@@ -1120,3 +1130,86 @@ describe('git-source routes: statuses-cache invalidation', () => {
expect(mockInvalidateNodeCaches).not.toHaveBeenCalled();
});
});
describe('POST /api/stacks/:stackName/git-source/apply fingerprint', () => {
it('returns 400 PLAN_FINGERPRINT_REQUIRED when the body omits planFingerprint', async () => {
seedGitSource('existing-stack');
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/apply')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ commitSha: 'abc123', deploy: false });
expect(res.status).toBe(400);
expect(res.body.code).toBe('PLAN_FINGERPRINT_REQUIRED');
});
it('returns 409 STALE_PLAN with the replacement plan attached', async () => {
seedGitSource('existing-stack');
const plan = {
blocked: false,
counts: {
add: 0, modify: 1, delete: 0, rename: 0, unchanged: 0,
localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0,
},
operations: [{ path: 'compose.yaml', op: 'modify' as const, role: 'compose-primary' as const }],
invocation: { candidateChanged: false, liveDiverged: false },
};
const applySpy = vi.spyOn(GitSourceService.getInstance(), 'apply')
.mockRejectedValue(new GitSourceError('STALE_PLAN', 'stale', { plan, planFingerprint: 'fp-new' }));
try {
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/apply')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ commitSha: 'abc123', planFingerprint: 'fp-old', deploy: false });
expect(res.status).toBe(409);
expect(res.body.code).toBe('STALE_PLAN');
expect(res.body.planFingerprint).toBe('fp-new');
expect(res.body.plan).toEqual(plan);
expect(JSON.stringify(res.body)).not.toContain('SUPER-SECRET');
} finally {
applySpy.mockRestore();
}
});
});
describe('POST /api/stacks/:stackName/git-source/pull permissions and actor', () => {
it('denies pull without stack:edit', async () => {
seedGitSource('existing-stack');
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/pull')
.set('Authorization', `Bearer ${jwt.sign({ username: 'viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' })}`);
expect([401, 403]).toContain(res.status);
});
it('passes the authenticated username as the pull actor', async () => {
seedGitSource('existing-stack');
const pullSpy = vi.spyOn(GitSourceService.getInstance(), 'pull').mockResolvedValue({
commitSha: 'abc',
validation: { ok: true },
refusals: [],
manifestSummary: null,
candidateReady: true,
warnings: [],
plan: {
blocked: false,
counts: {
add: 0, modify: 0, delete: 0, rename: 0, unchanged: 1,
localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0,
},
operations: [],
invocation: { candidateChanged: false, liveDiverged: false },
},
planFingerprint: 'fp',
});
try {
const res = await request(app)
.post('/api/stacks/existing-stack/git-source/pull')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(pullSpy).toHaveBeenCalledWith('existing-stack', { actor: TEST_USERNAME });
expect(JSON.stringify(res.body)).not.toContain('incomingCompose');
expect(JSON.stringify(res.body)).not.toContain('hasLocalChanges');
} finally {
pullSpy.mockRestore();
}
});
});
+302 -27
View File
@@ -176,6 +176,8 @@ async function cleanupStackDir(name: string) {
// ── Tests ──────────────────────────────────────────────────────────────
const SKIP_PLAN_FINGERPRINT = { requirePlanFingerprint: false as const };
describe('GitSourceService.hashContent', () => {
it('produces stable hashes for identical inputs', () => {
const svc = GitSourceService.getInstance();
@@ -566,7 +568,7 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
expect(row?.compose_paths).toEqual(['compose.yaml', 'override.yaml']);
});
it('apply refuses a stale-identity manifest with a detach-first instruction', async () => {
it('refuses a stale-identity manifest with a detach-first instruction', async () => {
const sha = 'abc1234567890abc1234567890abc1234567890a';
await seedSource('id-change-apply');
// Manifest stamped for a different repository than the source row.
@@ -574,14 +576,8 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
const svc = GitSourceService.getInstance();
mockSuccessfulClone({ sha });
await svc.pull('id-change-apply');
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
try {
await expect(svc.apply('id-change-apply', sha))
.rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/Detach the Git source/) });
} finally {
validateSpy.mockRestore();
}
await expect(svc.pull('id-change-apply'))
.rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/Detach the Git source/) });
});
});
});
@@ -852,6 +848,43 @@ describe('GitSourceService pending lifecycle', () => {
svc.dismissPending('pending-stack');
expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBeNull();
});
it('clearGitSourceAppliedRevision clears pending plan columns', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'clear-pending-plan',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const db = DatabaseService.getInstance();
db.setGitSourcePending('clear-pending-plan', 'sha-pend', 'blob', null, {
fingerprint: 'fp-clear',
blocked: true,
summary: '{"fingerprint":"fp-clear"}',
});
const before = db.getGitSource('clear-pending-plan');
expect(before?.pending_plan_fingerprint).toBe('fp-clear');
expect(before?.pending_plan_blocked).toBe(true);
expect(before?.pending_plan_summary).toBeTruthy();
db.clearGitSourceAppliedRevision('clear-pending-plan');
const after = db.getGitSource('clear-pending-plan');
expect(after?.last_applied_commit_sha).toBeNull();
expect(after?.pending_commit_sha).toBeNull();
expect(after?.pending_compose_content).toBeNull();
expect(after?.pending_env_content).toBeNull();
expect(after?.pending_fetched_at).toBeNull();
expect(after?.pending_plan_fingerprint).toBeNull();
expect(after?.pending_plan_blocked).toBeNull();
expect(after?.pending_plan_summary).toBeNull();
});
});
describe('GitSourceService.handleWebhookPull debounce', () => {
@@ -1204,6 +1237,8 @@ describe('GitSourceService.createStackFromGit', () => {
expect(result.envWritten).toBe(false);
expect(result.source.last_applied_commit_sha).toBe(sha);
expect(result.source.pending_commit_sha).toBeNull();
expect(result.source.last_plan_outcome).toBe('applied');
expect(result.source.last_plan_fingerprint).toBeTruthy();
// The manifest cache is persisted after the row insert (audit S-2):
// the immediate response and the DB row report the real state, not
@@ -1227,6 +1262,49 @@ describe('GitSourceService.createStackFromGit', () => {
}
});
it('builds the change plan before creating the active stack directory', async () => {
const sha = 'planbefore11112222333344445555666677778888';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
sha,
});
const svc = GitSourceService.getInstance();
const { GitChangePlanService } = await import('../services/GitChangePlanService');
const { FileSystemService } = await import('../services/FileSystemService');
let stackExistedDuringPlan = true;
const origBuild = GitChangePlanService.prototype.build;
const buildSpy = vi.spyOn(GitChangePlanService.prototype, 'build').mockImplementation(async function (this: InstanceType<typeof GitChangePlanService>, input) {
stackExistedDuringPlan = fs.existsSync(path.join(process.env.COMPOSE_DIR!, input.stackName));
return origBuild.call(this, input);
});
const origCreate = FileSystemService.prototype.createStack;
const createSpy = vi.spyOn(FileSystemService.prototype, 'createStack').mockImplementation(async function (this: InstanceType<typeof FileSystemService>, name: string) {
expect(buildSpy).toHaveBeenCalled();
return origCreate.call(this, name);
});
try {
await svc.createStackFromGit({
stackName: 'create-plan-first',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(stackExistedDuringPlan).toBe(false);
expect(buildSpy.mock.invocationCallOrder[0]).toBeLessThan(createSpy.mock.invocationCallOrder[0]);
await cleanupStackDir('create-plan-first');
} finally {
buildSpy.mockRestore();
createSpy.mockRestore();
}
});
it('multi-file create then pull reports no local changes (hash is path-independent)', async () => {
const sha = 'aaaa1111bbbb2222cccc3333dddd4444eeee5555';
mockSuccessfulClone({
@@ -1258,7 +1336,11 @@ describe('GitSourceService.createStackFromGit', () => {
// stored hash was computed from repo paths while the disk read uses the
// materialized paths (primary -> compose.yaml). This was the regression.
const pull = await svc.pull('mf-clean-pull');
expect(pull.hasLocalChanges).toBe(false);
expect(pull.plan).toBeTruthy();
expect(pull.plan?.blocked).toBe(false);
expect(pull.plan?.counts.localModified).toBe(0);
expect(pull).not.toHaveProperty('hasLocalChanges');
expect(pull).not.toHaveProperty('incomingCompose');
await cleanupStackDir('mf-clean-pull');
});
@@ -1413,6 +1495,8 @@ describe('GitSourceService.createStackFromGit', () => {
});
describe('GitSourceService.apply', () => {
const skipFingerprint = SKIP_PLAN_FINGERPRINT;
async function seedPending(stackName: string, composeContent: string, commitSha: string) {
mockSuccessfulClone({ compose: composeContent, sha: commitSha });
const svc = GitSourceService.getInstance();
@@ -1458,7 +1542,7 @@ describe('GitSourceService.apply', () => {
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
try {
const result = await svc.apply('apply-deploy-gate', sha, { deploy: true });
const result = await svc.apply('apply-deploy-gate', sha, { deploy: true, ...skipFingerprint });
expect(result.deployed).toBe(true);
expect(deploySpy).toHaveBeenCalledWith('apply-deploy-gate', undefined, undefined, {
source: 'git_apply',
@@ -1492,7 +1576,7 @@ describe('GitSourceService.apply', () => {
try {
// Assert the return SHAPE: apply must not throw, deployError must
// carry the failure detail so the UI can surface "applied but not deployed".
const result = await svc.apply('apply-deploy-fail', sha, { deploy: true });
const result = await svc.apply('apply-deploy-fail', sha, { deploy: true, ...skipFingerprint });
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
expect(result.deployError).toBeTruthy();
@@ -1542,9 +1626,8 @@ describe('GitSourceService.apply', () => {
await svc.pull(stackName);
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
try {
await expect(svc.apply(stackName, sha)).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringMatching(/does not manage/),
await expect(svc.apply(stackName, sha, skipFingerprint)).rejects.toMatchObject({
code: 'PLAN_BLOCKED',
});
// The local file is preserved byte-for-byte.
const onDisk = await fsSvc.readStackFile(stackName, 'configs/app.json');
@@ -1607,7 +1690,7 @@ describe('GitSourceService.apply', () => {
});
try {
const result = await svc.apply('apply-policy-block', sha, { deploy: true });
const result = await svc.apply('apply-policy-block', sha, { deploy: true, ...skipFingerprint });
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
@@ -1838,7 +1921,7 @@ describe('GitSourceService multi-file create + apply flow', () => {
const row = DatabaseService.getInstance().getGitSource('multi-pull');
expect(row?.pending_commit_sha).toBe(sha);
const applied = await svc.apply('multi-pull', pull.commitSha);
const applied = await svc.apply('multi-pull', pull.commitSha, SKIP_PLAN_FINGERPRINT);
expect(applied.applied).toBe(true);
const after = DatabaseService.getInstance().getGitSource('multi-pull');
@@ -1908,7 +1991,13 @@ describe('GitSourceService pending blob decode branches', () => {
it('round-trips the v3 blob with candidate path and inventory', () => {
const s = svc() as unknown as DecodeApi;
const encoded = s.encodePendingCompose([{ path: 'compose.yaml', content: 'x' }], null, 'generations/candidate-abc', { inputs: [], refusals: [], buildContexts: [] });
const encoded = s.crypto.encrypt(JSON.stringify({
v: 3,
files: [{ path: 'compose.yaml', content: 'x' }],
contextDir: null,
candidateRelPath: 'generations/candidate-abc',
inventory: { inputs: [], refusals: [], buildContexts: [] },
}));
const decoded = s.decodePendingCompose(encoded);
expect(decoded.candidateRelPath).toBe('generations/candidate-abc');
expect(decoded.files[0].content).toBe('x');
@@ -1933,7 +2022,7 @@ describe('GitSourceService pending blob decode branches', () => {
it('rejects a corrupt v3 blob as corrupt state instead of falling back to legacy', () => {
const s = svc() as unknown as DecodeApi;
const encoded = s.crypto.encrypt('{"v":3 not json');
expect(() => s.decodePendingCompose(encoded)).toThrow(/corrupt/);
expect(() => s.decodePendingCompose(encoded)).toThrow(/cannot be reviewed/);
});
});
@@ -2229,7 +2318,7 @@ describe('GitSourceService managed-area lifecycle', () => {
});
describe('GitSourceService legacy pending apply (migration path)', () => {
it('applies a v2 pending blob via the historical path and builds a migrated manifest', async () => {
it('refuses a v2 pending blob and returns LEGACY_PENDING', async () => {
const sha = '9999aaa9999aaa9999aaa9999aaa9999aaa9999a';
const svc = GitSourceService.getInstance();
const db = DatabaseService.getInstance();
@@ -2263,11 +2352,12 @@ describe('GitSourceService legacy pending apply (migration path)', () => {
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
try {
const applied = await svc.apply('legacy-apply', sha, { deploy: false });
expect(applied.applied).toBe(true);
expect(await fsSvc.getStackContent('legacy-apply')).toContain('image: nginx');
const row = db.getGitSource('legacy-apply');
expect(row?.manifest_state).toBe('migrated');
await expect(svc.apply('legacy-apply', sha, { deploy: false })).rejects.toMatchObject({
code: 'LEGACY_PENDING',
});
const disk = await fsSvc.getStackContent('legacy-apply').catch(() => '');
expect(disk).toContain('nginx:latest');
expect(disk).not.toContain('services:\n web:');
} finally {
validateSpy.mockRestore();
await cleanupStackDir('legacy-apply');
@@ -2352,7 +2442,7 @@ describe('GitSourceService sync-env stacks with a repo .env (audit C-2)', () =>
autoDeployOnApply: false,
});
const pull1 = await svc.pull('sync-env-double');
const apply1 = await svc.apply('sync-env-double', pull1.commitSha, { deploy: false });
const apply1 = await svc.apply('sync-env-double', pull1.commitSha, { deploy: false, ...SKIP_PLAN_FINGERPRINT });
expect(apply1.applied).toBe(true);
// The manifest has exactly one .env entry.
const manifest = await svc.getManifest('sync-env-double');
@@ -2362,7 +2452,7 @@ describe('GitSourceService sync-env stacks with a repo .env (audit C-2)', () =>
// Second cycle must not raise the divergence refusal.
const pull2 = await svc.pull('sync-env-double');
const apply2 = await svc.apply('sync-env-double', pull2.commitSha, { deploy: false });
const apply2 = await svc.apply('sync-env-double', pull2.commitSha, { deploy: false, ...SKIP_PLAN_FINGERPRINT });
expect(apply2.applied).toBe(true);
void db;
} finally {
@@ -2371,3 +2461,188 @@ describe('GitSourceService sync-env stacks with a repo .env (audit C-2)', () =>
}
});
});
describe('GitSourceService classified plan fingerprint', () => {
it('refuses public apply without a fingerprint and binds the pulled fingerprint', async () => {
const sha = 'ffff0000ffff0000ffff0000ffff0000ffff0000';
mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const { FileSystemService } = await import('../services/FileSystemService');
await FileSystemService.getInstance().createStack('fp-bind');
await svc.upsert({
stackName: 'fp-bind',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
try {
const pull = await svc.pull('fp-bind', { actor: 'alice' });
expect(pull.planFingerprint).toMatch(/^[a-f0-9]{64}$/);
expect(pull.plan?.blocked).toBe(false);
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
const acts = DatabaseService.getInstance().getStackActivity(nodeId, 'fp-bind', { limit: 20 });
expect(acts.some((a: { category?: string; actor_username?: string | null }) =>
a.category === 'git_pull_ready' && a.actor_username === 'alice',
)).toBe(true);
await expect(svc.apply('fp-bind', sha)).rejects.toMatchObject({ code: 'PLAN_FINGERPRINT_REQUIRED' });
await expect(svc.apply('fp-bind', sha, { planFingerprint: 'deadbeef' })).rejects.toMatchObject({
code: 'STALE_PLAN',
});
const applied = await svc.apply('fp-bind', sha, { planFingerprint: pull.planFingerprint! });
expect(applied.applied).toBe(true);
} finally {
validateSpy.mockRestore();
await cleanupStackDir('fp-bind');
}
});
it('lets a reviewed apply record invocation drift and refuses unattended apply', async () => {
const sha = 'aa11bb22cc33dd44ee55ff6677889900aabbccdd';
mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const { FileSystemService } = await import('../services/FileSystemService');
const fsSvc = FileSystemService.getInstance();
try {
await svc.createStackFromGit({
stackName: 'inv-drift',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: 'app',
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
await fsSvc.writeStackFile('inv-drift', '.env', 'FOO=1\n');
const pull = await svc.pull('inv-drift');
expect(pull.plan?.blocked).toBe(false);
expect(pull.plan?.invocation.liveDiverged).toBe(true);
await expect(svc.apply('inv-drift', sha, SKIP_PLAN_FINGERPRINT)).rejects.toMatchObject({
code: 'PLAN_BLOCKED',
message: expect.stringMatching(/invocation/i),
});
expect((await fsSvc.readStackFile('inv-drift', '.env')).content).toBe('FOO=1\n');
expect(DatabaseService.getInstance().getGitSource('inv-drift')?.pending_commit_sha).toBe(sha);
const applied = await svc.apply('inv-drift', sha, { planFingerprint: pull.planFingerprint! });
expect(applied.applied).toBe(true);
expect((await fsSvc.readStackFile('inv-drift', '.env')).content).toBe('FOO=1\n');
} finally {
validateSpy.mockRestore();
await cleanupStackDir('inv-drift');
}
});
it('keeps operationId across a live-file recompute and flips GET pending to blocked', async () => {
const sha = 'eeee1111eeee1111eeee1111eeee1111eeee1111';
mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const { FileSystemService } = await import('../services/FileSystemService');
const fsSvc = FileSystemService.getInstance();
await fsSvc.createStack('fp-stale-live');
await svc.upsert({
stackName: 'fp-stale-live',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
try {
const pull = await svc.pull('fp-stale-live');
const row = DatabaseService.getInstance().getGitSource('fp-stale-live');
const decoded = (svc as unknown as {
decodePendingCompose: (raw: string) => { operationId: string | null };
}).decodePendingCompose(row!.pending_compose_content!);
expect(decoded.operationId).toBeTruthy();
await fsSvc.saveStackContent('fp-stale-live', 'services:\n web:\n image: nginx:local\n');
await expect(svc.apply('fp-stale-live', sha, { planFingerprint: pull.planFingerprint! }))
.rejects.toMatchObject({ code: 'STALE_PLAN' });
const after = DatabaseService.getInstance().getGitSource('fp-stale-live');
const decodedAfter = (svc as unknown as {
decodePendingCompose: (raw: string) => { operationId: string | null };
}).decodePendingCompose(after!.pending_compose_content!);
expect(decodedAfter.operationId).toBe(decoded.operationId);
const publicSrc = svc.get('fp-stale-live');
expect(publicSrc?.pending_plan?.blocked).toBe(true);
expect(publicSrc?.pending_plan?.fingerprint).not.toBe(pull.planFingerprint);
} finally {
validateSpy.mockRestore();
await cleanupStackDir('fp-stale-live');
}
});
it('refuses an incomplete v4 pending blob as PLAN_UNAVAILABLE', async () => {
const sha = 'bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222';
const svc = GitSourceService.getInstance();
const db = DatabaseService.getInstance();
const { FileSystemService } = await import('../services/FileSystemService');
await FileSystemService.getInstance().createStack('plan-unavail');
db.upsertGitSource({
stack_name: 'plan-unavail',
repo_url: 'https://github.com/example/repo.git',
branch: 'main',
compose_path: 'compose.yaml',
compose_paths: ['compose.yaml'],
context_dir: null,
sync_env: false,
env_path: null,
auth_type: 'none',
encrypted_token: null,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: null,
last_applied_content_hash: null,
pending_commit_sha: sha,
pending_compose_content: null,
pending_env_content: null,
pending_fetched_at: null,
last_debounce_at: null,
});
const svcPriv = svc as unknown as { crypto: { encrypt(s: string): string } };
db.setGitSourcePending(
'plan-unavail',
sha,
svcPriv.crypto.encrypt(JSON.stringify({
v: 4,
files: [{ path: 'compose.yaml', content: 'services:\n web:\n image: nginx\n' }],
contextDir: null,
candidateRelPath: 'generations/cand',
inventory: { inputs: [], refusals: [], buildContexts: [] },
})),
null,
);
try {
await expect(svc.apply('plan-unavail', sha, SKIP_PLAN_FINGERPRINT)).rejects.toMatchObject({
code: 'PLAN_UNAVAILABLE',
});
} finally {
await cleanupStackDir('plan-unavail');
}
});
});
@@ -238,3 +238,23 @@ describe('DatabaseService.addNotificationHistory (no per-insert prune)', () => {
expect(aActivity.map((e: any) => e.message)).toEqual(['first']);
});
});
describe('DatabaseService.getStackActivity git categories', () => {
it('returns git change-plan history categories', () => {
const ts = Date.now();
for (const category of ['git_pull_ready', 'git_plan_blocked', 'git_apply', 'git_create'] as const) {
db.addNotificationHistory(0, {
level: 'info',
category,
message: category,
timestamp: ts,
stack_name: 'git-act',
actor_username: 'alice',
});
}
const out = db.getStackActivity(0, 'git-act', { limit: 50 });
expect(out.map((e: { category?: string }) => e.category).sort()).toEqual(
['git_apply', 'git_create', 'git_plan_blocked', 'git_pull_ready'].sort(),
);
});
});