Files
sencho/backend/src/__tests__/git-source-service.test.ts
T

1554 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 validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
try {
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');
} finally {
validateSpy.mockRestore();
}
});
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');
});
});