Files
sencho/backend/src/__tests__/git-source-service.test.ts
T
Anso 6527bc971b feat(security): gate deploys on exploitation risk, not just severity (#1432)
Scan-policy deploy gates can now block on a known-exploited CVE (CISA KEV)
and on a fixable Critical/High finding, in addition to an optional severity
threshold. New policies default risk-first (KEV and fixable on, severity off);
existing policies keep their severity-only behavior. CVSS stays captured for
context but is never the sole basis for a block, and a finding whose
exploitability cannot be confirmed is treated as risky rather than safe
(incomplete scan detail fails closed on KEV/fixable inputs).

The decision logic is shared between the pre-deploy gate and the informational
post-scan banner via a pure helper, so the two never disagree. Block messages
and the block dialog now name the conditions an image matched. Backend and
frontend gates move together, the new inputs replicate across the fleet, and a
blocking policy with no active input is rejected on both sides.
2026-06-24 20:05:17 -04:00

1549 lines
65 KiB
TypeScript

/**
* Unit tests for GitSourceService.
*
* Covers:
* - hashContent determinism and env separation
* - validateCompose YAML pre-check (empty / non-object / syntax error)
* - Token round-trip via upsert: encryption, has_token projection, undefined/null/empty/non-empty semantics
* - Apply-matrix rejection (auto_deploy requires auto_apply)
* - Error code mapping from isomorphic-git failures (REPO_NOT_FOUND, AUTH_FAILED, BRANCH_NOT_FOUND, NETWORK_TIMEOUT)
* - Credential scrubbing in surfaced error messages
* - Pending state lifecycle (setPending -> apply clears -> dismissPending clears)
* - Webhook debounce enforcement
* - Per-stack mutex serialization ordering
*/
import crypto from 'crypto';
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockGitClone, mockGitLog } = vi.hoisted(() => ({
mockGitClone: vi.fn(),
mockGitLog: vi.fn(),
}));
vi.mock('isomorphic-git', () => {
const api = { clone: mockGitClone, log: mockGitLog };
return { default: api, clone: mockGitClone, log: mockGitLog };
});
vi.mock('isomorphic-git/http/node', () => ({ default: {} }));
let tmpDir: string;
let GitSourceService: typeof import('../services/GitSourceService').GitSourceService;
let GitSourceError: typeof import('../services/GitSourceService').GitSourceError;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ GitSourceService, GitSourceError } = await import('../services/GitSourceService'));
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
mockGitClone.mockReset();
mockGitLog.mockReset();
// Wipe persisted git sources between tests
const db = DatabaseService.getInstance();
for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name);
for (const p of db.getScanPolicies()) db.deleteScanPolicy(p.id);
});
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Stub out isomorphic-git so that `clone` writes a minimal compose file into
* the caller's temp dir and `log` returns a deterministic commit sha. Returns
* the sha so tests can compare.
*/
function mockSuccessfulClone(options: {
compose?: string;
env?: string | null;
composePath?: string;
envPath?: string | null;
sha?: string;
/**
* Additional repo-relative files to write into the clone temp dir on top of
* the primary compose file. Lets multi-file tests stage base+override layouts.
*/
extraFiles?: Record<string, string>;
} = {}) {
const {
compose = 'services:\n web:\n image: nginx\n',
env = null,
composePath = 'compose.yaml',
envPath = null,
sha = 'abc1234567890abc1234567890abc1234567890a',
extraFiles = {},
} = options;
mockGitClone.mockImplementation(async (args: { dir: string }) => {
const { promises: fsp } = await import('fs');
const path = await import('path');
const composeAbs = path.join(args.dir, composePath);
await fsp.mkdir(path.dirname(composeAbs), { recursive: true });
await fsp.writeFile(composeAbs, compose, 'utf-8');
for (const [rel, content] of Object.entries(extraFiles)) {
const abs = path.join(args.dir, rel);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.writeFile(abs, content, 'utf-8');
}
if (env !== null && envPath) {
const envAbs = path.join(args.dir, envPath);
await fsp.mkdir(path.dirname(envAbs), { recursive: true });
await fsp.writeFile(envAbs, env, 'utf-8');
}
});
mockGitLog.mockResolvedValue([{ oid: sha }]);
return sha;
}
/** Wrap a single compose string in the ComposeFile[] shape the new APIs take. */
function asFiles(content: string): import('../services/GitSourceService').ComposeFile[] {
return [{ path: 'compose.yaml', content }];
}
/** Best-effort teardown for tests that materialize a stack on disk. */
async function cleanupStackDir(name: string) {
const { FileSystemService } = await import('../services/FileSystemService');
try {
await FileSystemService.getInstance().deleteStack(name);
} catch {
// directory may not exist; ignore
}
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('GitSourceService.hashContent', () => {
it('produces stable hashes for identical inputs', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent(asFiles('services:\n web: nginx\n'), 'FOO=bar');
const b = svc.hashContent(asFiles('services:\n web: nginx\n'), 'FOO=bar');
expect(a).toBe(b);
expect(a).toMatch(/^[a-f0-9]{64}$/);
});
it('distinguishes env=null from env=""', () => {
const svc = GitSourceService.getInstance();
const nullHash = svc.hashContent(asFiles('x: 1'), null);
const emptyHash = svc.hashContent(asFiles('x: 1'), '');
// Both hash-empty-string after null-coalesce, so they should match by design.
expect(nullHash).toBe(emptyHash);
});
it('changes when compose content changes', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent(asFiles('x: 1'), null);
const b = svc.hashContent(asFiles('x: 2'), null);
expect(a).not.toBe(b);
});
it('changes when env content changes', () => {
const svc = GitSourceService.getInstance();
const a = svc.hashContent(asFiles('x: 1'), 'A=1');
const b = svc.hashContent(asFiles('x: 1'), 'A=2');
expect(a).not.toBe(b);
});
it('does not confuse compose|env boundary (uses NUL separator)', () => {
const svc = GitSourceService.getInstance();
// If the separator were absent, "ab" + "cd" would equal "abc" + "d".
const a = svc.hashContent(asFiles('ab'), 'cd');
const b = svc.hashContent(asFiles('abc'), 'd');
expect(a).not.toBe(b);
});
it('keeps the single-file hash stable vs the legacy content+env formula', () => {
const svc = GitSourceService.getInstance();
// Legacy single-string hash was sha256(content + '\x00' + (env ?? '')).
const legacy = crypto
.createHash('sha256')
.update('x: 1')
.update('\x00')
.update('FOO=bar')
.digest('hex');
expect(svc.hashContent(asFiles('x: 1'), 'FOO=bar')).toBe(legacy);
});
it('folds ordered contents (not paths) for a multi-file set', () => {
const svc = GitSourceService.getInstance();
const base = { path: 'compose.yaml', content: 'a' };
const override = { path: 'infra/prod.yml', content: 'b' };
const ab = svc.hashContent([base, override], null);
const ba = svc.hashContent([override, base], null);
// Order-sensitive: swapping the two files changes the hash (content order).
expect(ab).not.toBe(ba);
// Path-INsensitive by design: the same contents in the same order hash equal
// regardless of path, so create (repo paths) and pull (materialized paths,
// primary -> compose.yaml) agree and a clean stack is not flagged as edited.
const repoPaths = svc.hashContent([{ path: 'infra/base.yml', content: 'a' }, { path: 'infra/prod.yml', content: 'b' }], null);
const localPaths = svc.hashContent([{ path: 'compose.yaml', content: 'a' }, { path: 'infra/prod.yml', content: 'b' }], null);
expect(repoPaths).toBe(localPaths);
// Content-sensitive: changing a file's content changes the hash.
expect(ab).not.toBe(svc.hashContent([base, { path: 'infra/prod.yml', content: 'B' }], null));
});
});
describe('GitSourceService.validateCompose (YAML pre-check)', () => {
const svc = () => GitSourceService.getInstance();
it('rejects empty content', async () => {
const r = await svc().validateCompose(asFiles(''), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/empty/i);
});
it('rejects a YAML array at the root', async () => {
const r = await svc().validateCompose(asFiles('- one\n- two\n'), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/mapping/i);
});
it('rejects a YAML scalar at the root', async () => {
const r = await svc().validateCompose(asFiles('42'), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/mapping/i);
});
it('rejects malformed YAML syntax', async () => {
const r = await svc().validateCompose(asFiles('services:\n web:\n image: "unterminated\n'), null, null);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/YAML parse error/i);
});
});
describe('GitSourceService.upsert (encryption + reachability)', () => {
it('stores an encrypted token and exposes has_token without leaking the value', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
const created = await svc.upsert({
stackName: 'enc-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
token: 'ghp_secret_token_value',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(created.has_token).toBe(true);
// Public projection should not contain the raw token
const serialized = JSON.stringify(created);
expect(serialized).not.toContain('ghp_secret_token_value');
// DB row holds an encrypted blob distinct from the plaintext
const row = DatabaseService.getInstance().getGitSource('enc-stack');
expect(row?.encrypted_token).toBeTruthy();
expect(row?.encrypted_token).not.toBe('ghp_secret_token_value');
});
it('preserves an existing token when update omits token (undefined)', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'keep-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
token: 'initial-token',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const originalEnc = DatabaseService.getInstance().getGitSource('keep-stack')?.encrypted_token;
await svc.upsert({
stackName: 'keep-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
// token omitted on purpose
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const after = DatabaseService.getInstance().getGitSource('keep-stack')?.encrypted_token;
expect(after).toBe(originalEnc);
});
it('clears the token when authType switches to "none"', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'clear-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'token',
token: 'will-be-cleared',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
await svc.upsert({
stackName: 'clear-stack',
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 row = DatabaseService.getInstance().getGitSource('clear-stack');
expect(row?.encrypted_token).toBeNull();
expect(row?.auth_type).toBe('none');
});
it('rejects auto_deploy_on_apply without auto_apply_on_webhook', async () => {
const svc = GitSourceService.getInstance();
await expect(svc.upsert({
stackName: 'bad-matrix',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: true,
})).rejects.toBeInstanceOf(GitSourceError);
// Dry-run clone must not have been attempted for the invalid matrix
expect(mockGitClone).not.toHaveBeenCalled();
});
it('does not persist when dry-run fetch fails', async () => {
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('404 not found'), { code: 'NotFoundError' }));
const svc = GitSourceService.getInstance();
await expect(svc.upsert({
stackName: 'unreachable',
repoUrl: 'https://github.com/example/nope.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
})).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
expect(DatabaseService.getInstance().getGitSource('unreachable')).toBeUndefined();
});
});
describe('GitSourceService error mapping', () => {
const svc = () => GitSourceService.getInstance();
const fetchParams = {
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
};
it('maps 401 with supplied token to AUTH_FAILED', async () => {
// A 401 only means "your token is wrong" when the caller actually sent one.
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 401 Unauthorized'), {
code: 'HttpError',
data: { statusCode: 401 },
}));
await expect(svc().fetchFromGit({ ...fetchParams, token: 'ghp_some_token_value' }))
.rejects.toMatchObject({ code: 'AUTH_FAILED' });
});
it('maps 401 without a token to REPO_NOT_FOUND with a private-repo hint', async () => {
// GitHub returns 404 for genuinely missing public repos but 401/403 can
// also reach us for private repos that the caller did not authenticate
// to. Without a supplied token, "check your token" is misleading, so we
// surface it as "not found or private" and suggest adding a PAT.
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 401 Unauthorized'), {
code: 'HttpError',
data: { statusCode: 401 },
}));
await expect(svc().fetchFromGit(fetchParams))
.rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/private/i) });
});
it('maps 404 HttpError to REPO_NOT_FOUND (not AUTH_FAILED)', async () => {
// Regression: isomorphic-git throws HttpError for every non-2xx, so a
// 404 on info/refs was previously misclassified as auth failure.
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 404 Not Found'), {
code: 'HttpError',
data: { statusCode: 404 },
}));
await expect(svc().fetchFromGit(fetchParams))
.rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/private/i) });
});
it('maps 404 with a supplied token to REPO_NOT_FOUND with a token-scope hint', async () => {
// GitHub returns 404 for both "missing repo" and "token lacks access",
// so when the caller did supply a token we point them at URL + scopes
// instead of "add a PAT" (which they already did).
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 404 Not Found'), {
code: 'HttpError',
data: { statusCode: 404 },
}));
await expect(svc().fetchFromGit({ ...fetchParams, token: 'ghp_some_token_value' }))
.rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/token has read access/i) });
});
it('maps 404/not-found errors to REPO_NOT_FOUND', async () => {
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('Repository not found'), { code: 'NotFoundError' }));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'REPO_NOT_FOUND' });
});
it('maps resolve-ref errors to BRANCH_NOT_FOUND', async () => {
// Message phrased to miss the REPO_NOT_FOUND regex ("could not resolve")
// so the BRANCH_NOT_FOUND branch is exercised.
mockGitClone.mockRejectedValueOnce(Object.assign(new Error('unknown ref nonexistent'), { code: 'ResolveRefError' }));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'BRANCH_NOT_FOUND' });
});
it('maps timeout errors to NETWORK_TIMEOUT', async () => {
mockGitClone.mockRejectedValueOnce(new Error('ETIMEDOUT connecting to host'));
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
});
it('maps a bare "fetch failed" TypeError with an ENOTFOUND cause to NETWORK_TIMEOUT', async () => {
// Node's global fetch() reports DNS failure as TypeError('fetch failed')
// with the real reason on err.cause. Without cause-unwrapping this fell
// through to a useless GIT_ERROR: "fetch failed".
mockGitClone.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('getaddrinfo ENOTFOUND github.com'), { code: 'ENOTFOUND' }),
}),
);
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
});
it('maps a "fetch failed" TypeError with an ECONNREFUSED cause to NETWORK_TIMEOUT', async () => {
mockGitClone.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:443'), { code: 'ECONNREFUSED' }),
}),
);
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
});
it('surfaces the host instead of bare "fetch failed" in transport errors', async () => {
mockGitClone.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }),
}),
);
try {
await svc().fetchFromGit(fetchParams);
expect.fail('should have thrown');
} catch (e) {
const err = e as Error;
expect(err.message).not.toMatch(/^fetch failed$/i);
expect(err.message).toContain('github.com');
}
});
it('unwraps a nested fetch cause chain to find the transport code', async () => {
mockGitClone.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: new TypeError('terminated', {
cause: Object.assign(new Error('reset'), { code: 'ECONNRESET' }),
}),
}),
);
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
});
it('maps a TLS certificate "fetch failed" cause to a certificate GIT_ERROR', async () => {
mockGitClone.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('self-signed certificate'), { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' }),
}),
);
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringMatching(/certificate/i),
});
});
it('surfaces FILE_NOT_FOUND when the compose path is missing from the clone', async () => {
mockGitClone.mockImplementation(async () => { /* clone empty repo */ });
mockGitLog.mockResolvedValue([{ oid: 'deadbeef' }]);
await expect(svc().fetchFromGit({
...fetchParams,
composePaths: ['missing/compose.yaml'],
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
it('scrubs inline credentials from surfaced error messages', async () => {
mockGitClone.mockRejectedValueOnce(new Error('Failed: https://user:supersecret@github.com/example/repo.git 500'));
try {
await svc().fetchFromGit(fetchParams);
expect.fail('should have thrown');
} catch (e) {
const err = e as Error;
expect(err.message).not.toContain('supersecret');
expect(err.message).toContain('***');
}
});
});
describe('countingBodyIterator (clone size cap)', () => {
function chunkStream(...sizes: number[]): AsyncIterableIterator<Uint8Array> {
async function* gen(): AsyncIterableIterator<Uint8Array> {
for (const s of sizes) yield new Uint8Array(s);
}
return gen();
}
it('passes chunks through unchanged while under the cap', async () => {
const { countingBodyIterator } = await import('../services/GitSourceService');
const controller = new AbortController();
const state = { exceeded: false, received: 0 };
const out: number[] = [];
for await (const c of countingBodyIterator(chunkStream(10, 20, 30), controller, 1000, state)) {
out.push(c.byteLength);
}
expect(out).toEqual([10, 20, 30]);
expect(state.exceeded).toBe(false);
expect(state.received).toBe(60);
expect(controller.signal.aborted).toBe(false);
});
it('aborts the transport and throws once the cumulative size exceeds the cap', async () => {
const { countingBodyIterator } = await import('../services/GitSourceService');
const controller = new AbortController();
const state = { exceeded: false, received: 0 };
await expect((async () => {
for await (const _c of countingBodyIterator(chunkStream(60, 60), controller, 100, state)) {
void _c;
}
})()).rejects.toThrow(/maximum allowed size/i);
expect(state.exceeded).toBe(true);
expect(controller.signal.aborted).toBe(true);
});
});
describe('GitSourceService.fetchFromGit (size limits)', () => {
const svc = () => GitSourceService.getInstance();
const fetchParams = {
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
};
it('rejects a compose file larger than the per-file read cap', async () => {
// The download cap bounds the compressed pack, not a single decompressed
// file, so readRepoFile guards the in-memory read by file size.
mockSuccessfulClone();
const { promises: fsp } = await import('fs');
const lstatSpy = vi.spyOn(fsp, 'lstat').mockResolvedValue({
isSymbolicLink: () => false,
size: 11 * 1024 * 1024,
} as Awaited<ReturnType<typeof fsp.lstat>>);
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringMatching(/too large/i),
});
lstatSpy.mockRestore();
});
it('surfaces a clone-size error when the download exceeds the cap', async () => {
// Drive the real size-counting transport the service injected into
// git.clone, with a tiny cap, and confirm fetchFromGit reports it as a
// clone-size error rather than a generic transport failure.
process.env.GITSOURCE_MAX_CLONE_BYTES = '8';
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(new Uint8Array(64), { status: 200 }),
);
mockGitClone.mockImplementation(async (args: {
http: { request: (r: { url: string; method: string; headers: Record<string, string> }) => Promise<{ body: AsyncIterableIterator<Uint8Array> }> };
}) => {
const resp = await args.http.request({ url: 'https://example.test/info/refs', method: 'GET', headers: {} });
for await (const chunk of resp.body) { void chunk; }
});
try {
await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringMatching(/exceeds the maximum clone size/i),
});
} finally {
delete process.env.GITSOURCE_MAX_CLONE_BYTES;
fetchSpy.mockRestore();
}
});
});
describe('GitSourceService pending lifecycle', () => {
it('dismissPending clears pending columns', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'pending-stack',
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('pending-stack', 'sha-xxx', 'services: {}', null);
expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBe('sha-xxx');
svc.dismissPending('pending-stack');
expect(db.getGitSource('pending-stack')?.pending_commit_sha).toBeNull();
});
});
describe('GitSourceService.handleWebhookPull debounce', () => {
it('returns skipped when invoked within the debounce window', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'debounce-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// Stamp a recent debounce timestamp directly
DatabaseService.getInstance().touchGitSourceDebounce('debounce-stack');
const result = await svc.handleWebhookPull('debounce-stack');
expect(result.status).toBe('skipped');
expect(result.message).toMatch(/rate limited/i);
});
it('returns error when stack has no Git source configured', async () => {
const svc = GitSourceService.getInstance();
const result = await svc.handleWebhookPull('does-not-exist');
expect(result.status).toBe('error');
expect(result.message).toMatch(/no git source/i);
});
it('runs a single clone for a concurrent webhook fan-out', async () => {
// The original failure: N webhooks for one push each ran a full clone
// because the debounce gate was read before the per-stack lock. The
// gate now lives inside the lock, so the first request stamps the
// window and the rest skip.
const sha = 'eeee555eeee555eeee555eeee555eeee555eeee5';
mockSuccessfulClone({ sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
await svc.upsert({
stackName: 'fanout-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// upsert performs a dry-run fetch; clear that call so we count only
// the clones triggered by the webhook fan-out below.
mockGitClone.mockClear();
const results = await Promise.all(
Array.from({ length: 5 }, () => svc.handleWebhookPull('fanout-stack')),
);
expect(mockGitClone.mock.calls.length).toBe(1);
expect(results.filter(r => r.status === 'success')).toHaveLength(1);
expect(results.filter(r => r.status === 'skipped')).toHaveLength(4);
validateSpy.mockRestore();
});
it('returns error when the pulled compose fails validation', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'webhook-validate-fail',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// upsert runs a dry-run fetch but not validateCompose, so the stub only
// affects the webhook pull below.
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: false, error: 'bad compose' });
const result = await svc.handleWebhookPull('webhook-validate-fail');
expect(result.status).toBe('error');
expect(result.message).toMatch(/validation failed/i);
validateSpy.mockRestore();
});
});
describe('GitSourceService per-stack mutex', () => {
it('serializes concurrent apply calls on the same stack', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance() as unknown as {
withStackLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
};
const order: string[] = [];
const makeJob = (label: string, delayMs: number) => async () => {
order.push(`start:${label}`);
await new Promise(r => setTimeout(r, delayMs));
order.push(`end:${label}`);
return label;
};
const [a, b, c] = await Promise.all([
svc.withStackLock('serialized', makeJob('A', 30)),
svc.withStackLock('serialized', makeJob('B', 10)),
svc.withStackLock('serialized', makeJob('C', 5)),
]);
expect([a, b, c]).toEqual(['A', 'B', 'C']);
// Each job must fully complete before the next one starts.
expect(order).toEqual([
'start:A', 'end:A',
'start:B', 'end:B',
'start:C', 'end:C',
]);
});
it('does not block work on a different stack', async () => {
const svc = GitSourceService.getInstance() as unknown as {
withStackLock<T>(name: string, fn: () => Promise<T>): Promise<T>;
};
const order: string[] = [];
const slow = svc.withStackLock('alpha', async () => {
order.push('alpha:start');
await new Promise(r => setTimeout(r, 40));
order.push('alpha:end');
});
const fast = svc.withStackLock('beta', async () => {
order.push('beta:start');
order.push('beta:end');
});
await Promise.all([slow, fast]);
// beta should have started and finished before alpha finished
expect(order.indexOf('beta:end')).toBeLessThan(order.indexOf('alpha:end'));
});
});
describe('GitSourceService.fetchFromGit (.git metadata guard)', () => {
const svc = () => GitSourceService.getInstance();
it('rejects compose paths that target the .git directory', async () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['.git/config'],
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
expect(mockGitClone).not.toHaveBeenCalled();
});
it('rejects nested .git paths', async () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['subdir/.git/HEAD'],
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
it('rejects env paths that target the .git directory', async () => {
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
envPath: '.git/config',
})).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' });
});
it('allows paths that merely contain the substring "git"', async () => {
mockSuccessfulClone({ composePath: 'gitops.yaml' });
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['gitops.yaml'],
})).resolves.toBeDefined();
});
it('rejects compose paths that are symbolic links', async () => {
mockSuccessfulClone();
const { promises: fsp } = await import('fs');
const lstatSpy = vi.spyOn(fsp, 'lstat').mockResolvedValue({
isSymbolicLink: () => true,
} as Awaited<ReturnType<typeof fsp.lstat>>);
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
})).rejects.toMatchObject({
code: 'FILE_NOT_FOUND',
message: expect.stringMatching(/symbolic link/i),
});
lstatSpy.mockRestore();
});
});
describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => {
const svc = () => GitSourceService.getInstance();
// Real pointer files start with this exact header (git-lfs spec v1).
const LFS_POINTER = 'version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 1024\n';
it('rejects an LFS-pointer compose file with a GIT_ERROR mentioning LFS', async () => {
mockSuccessfulClone({ compose: LFS_POINTER });
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
})).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringMatching(/LFS/i),
});
});
it('rejects an LFS-pointer env file with a GIT_ERROR mentioning LFS', async () => {
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
env: LFS_POINTER,
envPath: '.env',
});
await expect(svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
envPath: '.env',
})).rejects.toMatchObject({
code: 'GIT_ERROR',
message: expect.stringMatching(/LFS/i),
});
});
it('returns a submodule warning when .gitmodules is present', async () => {
mockGitClone.mockImplementation(async (args: { dir: string }) => {
const { promises: fsp } = await import('fs');
const p = await import('path');
await fsp.writeFile(p.join(args.dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n', 'utf-8');
await fsp.writeFile(
p.join(args.dir, '.gitmodules'),
'[submodule "vendor"]\n\tpath = vendor\n\turl = https://github.com/example/vendor.git\n',
'utf-8',
);
});
mockGitLog.mockResolvedValue([{ oid: 'abc1234567890abc1234567890abc1234567890a' }]);
const result = await svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
});
expect(result.warnings).toEqual(
expect.arrayContaining([expect.stringMatching(/submodules/i)]),
);
});
it('returns no warnings when .gitmodules is absent', async () => {
mockSuccessfulClone();
const result = await svc().fetchFromGit({
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
});
expect(result.warnings).toEqual([]);
});
});
describe('GitSourceService.pull', () => {
it('rejects when no Git source is configured for the stack', async () => {
const svc = GitSourceService.getInstance();
await expect(svc.pull('does-not-exist')).rejects.toMatchObject({ code: 'GIT_ERROR' });
});
});
describe('GitSourceService.createStackFromGit', () => {
it('creates a stack on disk, writes compose, and seeds last_applied columns', async () => {
const sha = 'fedcba9876543210fedcba9876543210fedcba98';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
sha,
});
const svc = GitSourceService.getInstance();
const result = await svc.createStackFromGit({
stackName: 'create-happy',
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(result.commitSha).toBe(sha);
expect(result.envWritten).toBe(false);
expect(result.source.last_applied_commit_sha).toBe(sha);
expect(result.source.pending_commit_sha).toBeNull();
const { FileSystemService } = await import('../services/FileSystemService');
const onDisk = await FileSystemService.getInstance().getStackContent('create-happy');
expect(onDisk).toContain('image: nginx');
await cleanupStackDir('create-happy');
});
it('multi-file create then pull reports no local changes (hash is path-independent)', async () => {
const sha = 'aaaa1111bbbb2222cccc3333dddd4444eeee5555';
mockSuccessfulClone({
composePath: 'infra/base.yml',
compose: 'services:\n web:\n image: nginx\n',
extraFiles: { 'infra/prod.yml': 'services:\n web:\n restart: always\n' },
sha,
});
const svc = GitSourceService.getInstance();
await svc.createStackFromGit({
stackName: 'mf-clean-pull',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// The primary is materialized as compose.yaml, the override at its repo path.
const row = DatabaseService.getInstance().getGitSource('mf-clean-pull');
expect(row?.applied_deploy_spec?.files).toEqual(['compose.yaml', 'infra/prod.yml']);
// Pulling the identical commit must NOT flag local edits, even though the
// 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);
await cleanupStackDir('mf-clean-pull');
});
it('resolves a nested compose_path and nested env_path into the stack dir', async () => {
const sha = 'deadbeef1234567890deadbeef1234567890abcd';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
env: 'FOO=nested\n',
composePath: 'apps/web/compose.yaml',
envPath: 'apps/web/.env',
sha,
});
const svc = GitSourceService.getInstance();
const result = await svc.createStackFromGit({
stackName: 'create-nested',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['apps/web/compose.yaml'],
contextDir: null,
syncEnv: true,
envPath: 'apps/web/.env',
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(result.envWritten).toBe(true);
expect(result.source.compose_path).toBe('apps/web/compose.yaml');
expect(result.source.env_path).toBe('apps/web/.env');
const { FileSystemService } = await import('../services/FileSystemService');
const env = await FileSystemService.getInstance().getEnvContent('create-nested');
expect(env).toBe('FOO=nested\n');
const row = DatabaseService.getInstance().getGitSource('create-nested');
expect(row?.env_path).toBe('apps/web/.env');
await cleanupStackDir('create-nested');
});
it('writes the env file when sync_env is enabled', async () => {
const sha = '0101010101010101010101010101010101010101';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
env: 'FOO=bar\n',
envPath: '.env',
sha,
});
const svc = GitSourceService.getInstance();
const result = await svc.createStackFromGit({
stackName: 'create-env',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: true,
envPath: '.env',
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(result.envWritten).toBe(true);
const { FileSystemService } = await import('../services/FileSystemService');
const env = await FileSystemService.getInstance().getEnvContent('create-env');
expect(env).toBe('FOO=bar\n');
await cleanupStackDir('create-env');
});
it('rejects an invalid apply-matrix without fetching or writing disk', async () => {
const svc = GitSourceService.getInstance();
await expect(svc.createStackFromGit({
stackName: 'create-bad-matrix',
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: true,
})).rejects.toBeInstanceOf(GitSourceError);
expect(mockGitClone).not.toHaveBeenCalled();
expect(DatabaseService.getInstance().getGitSource('create-bad-matrix')).toBeUndefined();
});
it('rejects when compose validation fails and leaves no stack/row behind', async () => {
mockSuccessfulClone({
// Non-mapping root is rejected by validateCompose() pre-check
compose: '- not-a-mapping\n',
});
const svc = GitSourceService.getInstance();
await expect(svc.createStackFromGit({
stackName: 'create-bad-yaml',
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,
})).rejects.toMatchObject({ code: 'GIT_ERROR' });
expect(DatabaseService.getInstance().getGitSource('create-bad-yaml')).toBeUndefined();
const { FileSystemService } = await import('../services/FileSystemService');
const stacks = await FileSystemService.getInstance().getStacks();
expect(stacks).not.toContain('create-bad-yaml');
});
it('rolls back the stack dir when a post-create step fails', async () => {
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
});
const { FileSystemService } = await import('../services/FileSystemService');
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent')
.mockRejectedValueOnce(new Error('simulated disk failure'));
const svc = GitSourceService.getInstance();
await expect(svc.createStackFromGit({
stackName: 'create-rollback',
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,
})).rejects.toThrow(/simulated disk failure/);
expect(DatabaseService.getInstance().getGitSource('create-rollback')).toBeUndefined();
const stacks = await FileSystemService.getInstance().getStacks();
expect(stacks).not.toContain('create-rollback');
saveSpy.mockRestore();
});
});
describe('GitSourceService.apply', () => {
async function seedPending(stackName: string, composeContent: string, commitSha: string) {
mockSuccessfulClone({ compose: composeContent, sha: commitSha });
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName,
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
await svc.pull(stackName);
return svc;
}
it('throws when pending has been cleared between pull and apply', async () => {
const svc = await seedPending('apply-cleared', 'services:\n x:\n image: alpine\n', 'aaaa111aaaa111aaaa111aaaa111aaaa111aaaa1');
DatabaseService.getInstance().clearGitSourcePending('apply-cleared');
await expect(svc.apply('apply-cleared', 'aaaa111aaaa111aaaa111aaaa111aaaa111aaaa1'))
.rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/no pending pull/i) });
});
it('throws when the commit sha does not match the pending sha', async () => {
const svc = await seedPending('apply-mismatch', 'services:\n x:\n image: alpine\n', 'bbbb222bbbb222bbbb222bbbb222bbbb222bbbb2');
await expect(svc.apply('apply-mismatch', 'deadbeef1234567890deadbeef1234567890dead'))
.rejects.toMatchObject({ code: 'GIT_ERROR', message: expect.stringMatching(/pending commit has changed/i) });
});
it('begins a deploy health gate after a successful apply-and-deploy', async () => {
const sha = 'eeee555eeee555eeee555eeee555eeee555eeee5';
const svc = await seedPending('apply-deploy-gate', 'services:\n x:\n image: alpine\n', sha);
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const { FileSystemService } = await import('../services/FileSystemService');
const { ComposeService } = await import('../services/ComposeService');
const { HealthGateService } = await import('../services/HealthGateService');
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue();
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'begin').mockReturnValue('gate-git');
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
const result = await svc.apply('apply-deploy-gate', sha, { deploy: true });
expect(result.deployed).toBe(true);
expect(deploySpy).toHaveBeenCalledWith('apply-deploy-gate');
expect(beginSpy).toHaveBeenCalledWith(nodeId, 'apply-deploy-gate', 'deploy', 'system:git-source');
validateSpy.mockRestore();
saveSpy.mockRestore();
deploySpy.mockRestore();
beginSpy.mockRestore();
});
it('returns deployError when the deploy step fails after writing to disk', async () => {
const sha = 'cccc333cccc333cccc333cccc333cccc333cccc3';
const svc = await seedPending('apply-deploy-fail', 'services:\n x:\n image: alpine\n', sha);
// Stub validation (docker compose config is expensive and not needed here)
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
// Stub file write (FileSystemService expects a real stack dir)
const { FileSystemService } = await import('../services/FileSystemService');
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue();
// Deploy will fail organically (docker CLI unavailable in the test env).
// We only 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 });
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
expect(result.deployError).toBeTruthy();
// Disk write happened; DB was marked applied even though deploy failed.
expect(saveSpy).toHaveBeenCalled();
const row = DatabaseService.getInstance().getGitSource('apply-deploy-fail');
expect(row?.last_applied_commit_sha).toBe(sha);
expect(row?.pending_commit_sha).toBeNull();
validateSpy.mockRestore();
saveSpy.mockRestore();
});
it('returns deployError and skips compose deploy when policy blocks apply deploy', async () => {
const sha = 'dddd444dddd444dddd444dddd444dddd444dddd4';
const svc = await seedPending('apply-policy-block', 'services:\n x:\n image: nginx:bad\n', sha);
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const { FileSystemService } = await import('../services/FileSystemService');
const { ComposeService } = await import('../services/ComposeService');
const { LicenseService } = await import('../services/LicenseService');
const TrivyService = (await import('../services/TrivyService')).default;
const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue();
const listImagesSpy = vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:bad']);
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue();
const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
const trivy = TrivyService.getInstance();
const trivyAvailableSpy = vi.spyOn(trivy, 'isTrivyAvailable').mockReturnValue(true);
const scanSpy = vi.spyOn(trivy, 'scanImagePreflight').mockResolvedValue({
id: 77,
node_id: 1,
image_ref: 'nginx:bad',
image_digest: null,
scanned_at: Date.now(),
total_vulnerabilities: 1,
critical_count: 1,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: 'CRITICAL',
os_info: null,
trivy_version: '0.50.0',
scan_duration_ms: null,
triggered_by: 'deploy-preflight',
status: 'completed',
error: null,
stack_context: 'apply-policy-block',
policy_evaluation: null,
});
DatabaseService.getInstance().createScanPolicy({
name: 'block-high',
node_id: null,
node_identity: '',
stack_pattern: 'apply-policy-block',
max_severity: 'HIGH',
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
const result = await svc.apply('apply-policy-block', sha, { deploy: true });
expect(result.applied).toBe(true);
expect(result.deployed).toBe(false);
expect(result.deployError).toContain('Policy "block-high" blocked deploy');
expect(deploySpy).not.toHaveBeenCalled();
validateSpy.mockRestore();
saveSpy.mockRestore();
listImagesSpy.mockRestore();
deploySpy.mockRestore();
tierSpy.mockRestore();
trivyAvailableSpy.mockRestore();
scanSpy.mockRestore();
});
});
describe('GitSourceService DB normalization (compose_paths back-compat)', () => {
it('reads back [compose_path] when a row stores compose_paths as null (legacy)', async () => {
mockSuccessfulClone({ composePath: 'stacks/web/compose.yaml' });
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'legacy-null',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['stacks/web/compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
// Simulate a row written before the multi-file column existed.
const db = DatabaseService.getInstance();
db.getDb().prepare('UPDATE stack_git_sources SET compose_paths = NULL WHERE stack_name = ?').run('legacy-null');
const row = db.getGitSource('legacy-null');
expect(row?.compose_paths).toEqual(['stacks/web/compose.yaml']);
expect(row?.compose_path).toBe('stacks/web/compose.yaml');
});
it('reads back [compose_path] when compose_paths holds an empty JSON array', async () => {
mockSuccessfulClone();
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'legacy-empty',
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.getDb().prepare(`UPDATE stack_git_sources SET compose_paths = '[]' WHERE stack_name = ?`).run('legacy-empty');
expect(db.getGitSource('legacy-empty')?.compose_paths).toEqual(['compose.yaml']);
});
it('backfills compose_paths to json_array(compose_path) for a NULL-column row', async () => {
// The migration runs json_array(compose_path) for any row whose
// compose_paths is NULL. Insert a NULL-column row, run the backfill SQL,
// and confirm the stored JSON is a one-element array of the legacy path.
mockSuccessfulClone({ composePath: 'deploy/compose.yaml' });
const svc = GitSourceService.getInstance();
await svc.upsert({
stackName: 'backfill-stack',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['deploy/compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const db = DatabaseService.getInstance();
db.getDb().prepare('UPDATE stack_git_sources SET compose_paths = NULL WHERE stack_name = ?').run('backfill-stack');
db.getDb().prepare(
`UPDATE stack_git_sources SET compose_paths = json_array(compose_path) WHERE compose_paths IS NULL`,
).run();
const stored = db.getDb()
.prepare('SELECT compose_paths FROM stack_git_sources WHERE stack_name = ?')
.get('backfill-stack') as { compose_paths: string };
expect(JSON.parse(stored.compose_paths)).toEqual(['deploy/compose.yaml']);
});
});
describe('GitSourceService multi-file create + apply flow', () => {
it('materializes both files and persists a non-null applied_deploy_spec for a two-file create', async () => {
const sha = '1111aaa1111aaa1111aaa1111aaa1111aaa1111a';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
composePath: 'infra/base.yml',
extraFiles: { 'infra/prod.yml': 'services:\n web:\n environment:\n - X=1\n' },
sha,
});
const svc = GitSourceService.getInstance();
// Isolate materialize behavior from docker availability.
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
const result = await svc.createStackFromGit({
stackName: 'multi-create',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
expect(result.commitSha).toBe(sha);
const row = DatabaseService.getInstance().getGitSource('multi-create');
expect(row?.compose_paths).toEqual(['infra/base.yml', 'infra/prod.yml']);
expect(row?.applied_deploy_spec).not.toBeNull();
expect(row?.applied_deploy_spec?.files).toEqual(['compose.yaml', 'infra/prod.yml']);
const { FileSystemService } = await import('../services/FileSystemService');
const fsSvc = FileSystemService.getInstance();
// Primary lands at the root compose.yaml; the additional file at its repo path.
expect(await fsSvc.getStackContent('multi-create')).toContain('image: nginx');
const prod = await fsSvc.readStackFile('multi-create', 'infra/prod.yml');
expect(prod.content).toContain('X=1');
validateSpy.mockRestore();
await cleanupStackDir('multi-create');
});
it('leaves applied_deploy_spec null for a plain single-file create', async () => {
const sha = '2222bbb2222bbb2222bbb2222bbb2222bbb2222b';
mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
await svc.createStackFromGit({
stackName: 'single-create',
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,
});
const row = DatabaseService.getInstance().getGitSource('single-create');
expect(row?.compose_paths).toEqual(['compose.yaml']);
expect(row?.applied_deploy_spec).toBeNull();
validateSpy.mockRestore();
await cleanupStackDir('single-create');
});
it('sets applied_deploy_spec for a single-file create that has a context_dir', async () => {
const sha = '3333ccc3333ccc3333ccc3333ccc3333ccc3333c';
mockSuccessfulClone({ compose: 'services:\n web:\n image: nginx\n', sha });
const svc = GitSourceService.getInstance();
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
await svc.createStackFromGit({
stackName: 'ctx-create',
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,
});
const row = DatabaseService.getInstance().getGitSource('ctx-create');
expect(row?.applied_deploy_spec).not.toBeNull();
expect(row?.applied_deploy_spec?.files).toEqual(['compose.yaml']);
expect(row?.applied_deploy_spec?.contextDir).toBe('app');
validateSpy.mockRestore();
await cleanupStackDir('ctx-create');
});
it('pulls a multi-file v2 pending blob and applies both files to disk', async () => {
const sha = '4444ddd4444ddd4444ddd4444ddd4444ddd4444d';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
composePath: 'infra/base.yml',
extraFiles: { 'infra/prod.yml': 'services:\n web:\n environment:\n - Y=2\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('multi-pull');
await svc.upsert({
stackName: 'multi-pull',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const pull = await svc.pull('multi-pull');
// Pending compose blob is encrypted; assert it round-trips by applying it.
const row = DatabaseService.getInstance().getGitSource('multi-pull');
expect(row?.pending_commit_sha).toBe(sha);
const applied = await svc.apply('multi-pull', pull.commitSha);
expect(applied.applied).toBe(true);
const after = DatabaseService.getInstance().getGitSource('multi-pull');
expect(after?.applied_deploy_spec?.files).toEqual(['compose.yaml', 'infra/prod.yml']);
expect(await fsSvc.getStackContent('multi-pull')).toContain('image: nginx');
expect((await fsSvc.readStackFile('multi-pull', 'infra/prod.yml')).content).toContain('Y=2');
validateSpy.mockRestore();
await cleanupStackDir('multi-pull');
});
it('keeps pending across an upsert with the SAME config and clears it when compose_paths change', async () => {
const sha = '5555eee5555eee5555eee5555eee5555eee5555e';
mockSuccessfulClone({
compose: 'services:\n web:\n image: nginx\n',
composePath: 'infra/base.yml',
extraFiles: {
'infra/prod.yml': 'services:\n web:\n environment:\n - Z=3\n',
// The changed-config upsert re-fetches this path; the dry-run reads
// every configured file, so it must exist in the clone.
'infra/staging.yml': 'services:\n web:\n environment:\n - Z=4\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('pending-config');
const baseInput = {
stackName: 'pending-config',
repoUrl: 'https://github.com/example/repo.git',
branch: 'main',
composePaths: ['infra/base.yml', 'infra/prod.yml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none' as const,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
};
await svc.upsert(baseInput);
await svc.pull('pending-config');
const db = DatabaseService.getInstance();
expect(db.getGitSource('pending-config')?.pending_commit_sha).toBe(sha);
// Same config -> pending must survive.
await svc.upsert(baseInput);
expect(db.getGitSource('pending-config')?.pending_commit_sha).toBe(sha);
// Changed compose_paths -> the captured pending blob no longer matches, clear it.
await svc.upsert({ ...baseInput, composePaths: ['infra/base.yml', 'infra/staging.yml'] });
expect(db.getGitSource('pending-config')?.pending_commit_sha).toBeNull();
validateSpy.mockRestore();
await cleanupStackDir('pending-config');
});
});