Files
add-and-commit/test/integration/action.test.ts
T
3446398e33 fix: validate denylisted git args on every token (#784)
Short-option clusters such as -Sm were treated as consuming the next
argument, which let --pathspec-from-file reach git and leak file
contents into workflow logs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 14:47:41 +00:00

592 lines
18 KiB
TypeScript

import * as fs from 'node:fs';
import * as path from 'node:path';
import {
commitAndPushToRemote,
createFixture,
type Fixture,
gitLog,
gitRevParse,
initNestedGitRepo,
listFilesAtHead,
remoteHasRef,
removeFile,
runAction,
writeFile,
} from './helpers';
describe('action integration', () => {
let fixture: Fixture | undefined;
beforeEach(() => {
fixture = createFixture();
});
afterEach(() => {
fixture?.cleanup();
fixture = undefined;
});
it('commits changes with push disabled and sets outputs', () => {
const f = fixture!;
writeFile(f.local, 'changed.txt', 'hello\n');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
message: 'Add changed.txt',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.outputs.pushed).toBe('false');
expect(result.outputs.commit_long_sha).toMatch(/^[0-9a-f]{40}$/);
expect(result.outputs.commit_sha).toBe(
result.outputs.commit_long_sha!.slice(0, 7),
);
const after = gitRevParse(f.local, 'HEAD');
expect(after).not.toBe(before);
expect(after).toBe(result.outputs.commit_long_sha);
expect(gitLog(f.local, '%s')).toBe('Add changed.txt');
expect(gitLog(f.local, '%an <%ae>')).toBe(
'Integration Tester <integration@example.com>',
);
expect(listFilesAtHead(f.local)).toContain('changed.txt');
expect(remoteHasRef(f.remote, 'HEAD')).toBe(true);
// Remote still on initial commit — nothing pushed.
expect(gitRevParse(f.remote, 'HEAD')).toBe(before);
});
it('does nothing when the working tree is clean', () => {
const f = fixture!;
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {push: 'false'});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('false');
expect(result.outputs.pushed).toBe('false');
expect(result.outputs.commit_long_sha || undefined).toBeUndefined();
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('dry_run reports without committing or changing the tree', () => {
const f = fixture!;
writeFile(f.local, 'dry-run.txt', 'preview\n');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
message: 'Would commit',
dry_run: 'true',
push: 'true',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('false');
expect(result.outputs.pushed).toBe('false');
expect(result.outputs.commit_long_sha || undefined).toBeUndefined();
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
expect(listFilesAtHead(f.local)).not.toContain('dry-run.txt');
expect(fs.existsSync(path.join(f.local, 'dry-run.txt'))).toBe(true);
expect(result.stdout).toMatch(/Dry run completed/i);
});
it('dry_run on a clean tree does not claim a commit', () => {
const f = fixture!;
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
dry_run: 'true',
push: 'true',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('false');
expect(result.outputs.pushed).toBe('false');
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
expect(result.stdout).toMatch(/nothing would be committed/i);
});
it('dry_run still refuses unexpected gitlinks without mutating HEAD', () => {
const f = fixture!;
initNestedGitRepo(f.local, 'embedded');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
message: 'Would embed repo',
dry_run: 'true',
push: 'false',
});
expect(result.status).not.toBe(0);
expect(result.outputs.committed).toBe('false');
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
expect(`${result.stdout}\n${result.stderr}`).toMatch(/gitlink/i);
});
it('rejects remote-helper overrides after -u in fetch args', () => {
const f = fixture!;
writeFile(f.local, 'fetch-args.txt', 'changed\n');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
message: 'Should not fetch with blocked args',
fetch: '-u --upl=evil',
push: 'false',
});
expect(result.status).not.toBe(0);
expect(result.outputs.committed).toBe('false');
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
expect(`${result.stdout}\n${result.stderr}`).toMatch(/not allowed/);
});
it('rejects glued quotes that would inject --force into push', () => {
const f = fixture!;
writeFile(f.local, 'glued-quotes.txt', 'changed\n');
const beforeRemote = gitRevParse(f.remote, 'HEAD');
const result = runAction(f, {
message: 'Should not push with glued quotes',
fetch: 'false',
push: "origin 'main'--force --set-upstream",
});
expect(result.status).not.toBe(0);
expect(result.outputs.pushed).toBe('false');
expect(gitRevParse(f.remote, 'HEAD')).toBe(beforeRemote);
expect(`${result.stdout}\n${result.stderr}`).toMatch(
/quoted segment immediately followed by non-whitespace/,
);
});
it('applies custom author and committer', () => {
const f = fixture!;
writeFile(f.local, 'id.txt', 'id\n');
const result = runAction(f, {
message: 'Custom identity',
author_name: 'Author Name',
author_email: 'author@example.com',
committer_name: 'Committer Name',
committer_email: 'committer@example.com',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(gitLog(f.local, '%an <%ae>')).toBe(
'Author Name <author@example.com>',
);
expect(gitLog(f.local, '%cn <%ce>')).toBe(
'Committer Name <committer@example.com>',
);
});
it('creates a local tag', () => {
const f = fixture!;
writeFile(f.local, 'tagged.txt', 'tag me\n');
const result = runAction(f, {
message: 'Tagged commit',
tag: 'v0.0.0-test',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.outputs.tagged).toBe('true');
expect(result.outputs.tag_pushed).toBe('false');
const tagSha = gitRevParse(f.local, 'refs/tags/v0.0.0-test');
expect(tagSha).toBe(result.outputs.commit_long_sha);
});
it('pushes the commit to the local bare remote', () => {
const f = fixture!;
writeFile(f.local, 'pushed.txt', 'push me\n');
const beforeRemote = gitRevParse(f.remote, 'HEAD');
const result = runAction(f, {
message: 'Push to bare remote',
push: 'true',
fetch: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.outputs.pushed).toBe('true');
const localHead = gitRevParse(f.local, 'HEAD');
const remoteHead = gitRevParse(f.remote, 'HEAD');
expect(remoteHead).toBe(localHead);
expect(remoteHead).not.toBe(beforeRemote);
expect(remoteHead).toBe(result.outputs.commit_long_sha);
});
it('creates a new branch and pushes it to the remote', () => {
const f = fixture!;
writeFile(f.local, 'branch.txt', 'branch me\n');
const result = runAction(f, {
message: 'Commit on new branch',
new_branch: 'integration-new-branch',
push: 'true',
fetch: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.outputs.pushed).toBe('true');
expect(remoteHasRef(f.remote, 'refs/heads/integration-new-branch')).toBe(
true,
);
const remoteBranchSha = gitRevParse(
f.remote,
'refs/heads/integration-new-branch',
);
expect(remoteBranchSha).toBe(result.outputs.commit_long_sha);
});
it('removes files with the remove input', () => {
const f = fixture!;
// Seed already has README.md; ensure it exists then remove via the action.
expect(listFilesAtHead(f.local)).toContain('README.md');
// Make a dirty tree so add+remove both run: touch another file and remove README.
writeFile(f.local, 'keep.txt', 'keep\n');
removeFile(f.local, 'README.md');
const result = runAction(f, {
message: 'Remove README',
add: 'keep.txt',
remove: 'README.md',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
const files = listFilesAtHead(f.local);
expect(files).not.toContain('README.md');
expect(files).toContain('keep.txt');
});
it('only commits files matched by selective add', () => {
const f = fixture!;
writeFile(f.local, 'include-me.txt', 'yes\n');
writeFile(f.local, 'skip-me.txt', 'no\n');
const result = runAction(f, {
message: 'Selective add',
add: 'include-me.txt',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
const files = listFilesAtHead(f.local);
expect(files).toContain('include-me.txt');
expect(files).not.toContain('skip-me.txt');
// Untracked file should still be on disk.
expect(fs.existsSync(path.join(f.local, 'skip-me.txt'))).toBe(true);
});
it('pull: true incorporates remote commits before committing', () => {
const f = fixture!;
commitAndPushToRemote(f, 'from-remote.txt', 'remote\n', 'Remote change');
writeFile(f.local, 'from-local.txt', 'local\n');
const result = runAction(f, {
message: 'Local change',
pull: 'true',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(/> Pulling from remote/);
expect(result.stdout).not.toMatch(/Not pulling from repo/);
const files = listFilesAtHead(f.local);
expect(files).toContain('from-remote.txt');
expect(files).toContain('from-local.txt');
});
it.each([
{pull: 'false', label: 'false'},
{pull: undefined, label: 'omitted'},
])('skips pull when pull is $label', ({pull}) => {
const f = fixture!;
commitAndPushToRemote(f, 'from-remote.txt', 'remote\n', 'Remote change');
writeFile(f.local, 'from-local.txt', 'local\n');
const result = runAction(f, {
message: 'Local change only',
...(pull !== undefined ? {pull} : {}),
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(/Not pulling from repo/);
const files = listFilesAtHead(f.local);
expect(files).toContain('from-local.txt');
expect(files).not.toContain('from-remote.txt');
});
it('pull with custom git args still pulls', () => {
const f = fixture!;
commitAndPushToRemote(f, 'from-remote.txt', 'remote\n', 'Remote change');
writeFile(f.local, 'from-local.txt', 'local\n');
const result = runAction(f, {
message: 'Local change',
pull: '--rebase --autostash',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(/> Pulling from remote/);
const files = listFilesAtHead(f.local);
expect(files).toContain('from-remote.txt');
expect(files).toContain('from-local.txt');
});
it('dry_run with pull: true reports a default pull without mutating', () => {
const f = fixture!;
writeFile(f.local, 'dry-pull.txt', 'preview\n');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
message: 'Would commit',
dry_run: 'true',
pull: 'true',
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('false');
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
expect(listFilesAtHead(f.local)).not.toContain('dry-pull.txt');
expect(result.stdout).toMatch(/> Would pull from remote\./);
expect(result.stdout).not.toMatch(/with: true/);
});
it('commits when cwd is an absolute path and process.cwd differs', () => {
const f = fixture!;
writeFile(f.local, 'abs-cwd.txt', 'absolute\n');
const before = gitRevParse(f.local, 'HEAD');
// Spawn from the fixture parent so process.cwd() !== the git work tree.
const spawnCwd = path.dirname(f.local);
const result = runAction(
f,
{
message: 'Absolute cwd commit',
cwd: f.local,
push: 'false',
},
{spawnCwd},
);
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toMatch(
new RegExp(`Running in ${f.local.replace(/\\/g, '\\\\')}`),
);
const after = gitRevParse(f.local, 'HEAD');
expect(after).not.toBe(before);
expect(after).toBe(result.outputs.commit_long_sha);
expect(listFilesAtHead(f.local)).toContain('abs-cwd.txt');
});
it('neutralizes workflow-command payloads in logs without rewriting the commit', () => {
const f = fixture!;
writeFile(f.local, 'payload.txt', 'x\n');
const payload = 'Normal title\n::stop-commands::7a3f9c1e';
const result = runAction(f, {
message: payload,
push: 'false',
});
expect(result.status).toBe(0);
expect(result.outputs.committed).toBe('true');
expect(result.stdout).toContain(
'Normal title\\u000a::stop-commands::7a3f9c1e',
);
expect(result.stdout.split(/\r?\n/)).not.toContain(
'::stop-commands::7a3f9c1e',
);
expect(result.stdout).not.toMatch(/^::stop-commands::/m);
// The commit keeps the raw newline; only the log is neutralized.
// %s joins a multi-line subject with spaces; %B is the raw message.
expect(gitLog(f.local, '%B')).toBe(payload);
expect(gitLog(f.local, '%B')).not.toContain('\\u000a');
});
it('fails clearly when cwd does not exist without dumping the bundle', () => {
const f = fixture!;
const missing = path.join(
path.dirname(f.local),
`missing-cwd-${process.pid}-${Date.now()}`,
);
const result = runAction(
f,
{
cwd: missing,
push: 'false',
},
{spawnCwd: path.dirname(f.local)},
);
expect(result.status).not.toBe(0);
const combined = `${result.stdout}\n${result.stderr}`;
expect(combined).toMatch(/not an existing directory/);
expect(combined).toContain(missing);
// Uncaught throws from the minified bundle dump the whole line of source.
expect(combined).not.toMatch(/function isObject/);
expect(combined.length).toBeLessThan(50_000);
});
describe('rejects --pathspec-from-file / --pathspec-file-nul', () => {
const MARKER_A = 'AAC_POC_SECRET_7f9c2e';
const MARKER_B = 'AAC_SECOND_LINE_91a4d8';
function writeDummyOutsideClone(f: Fixture): string {
const dummyPath = path.join(
path.dirname(f.local),
`pathspec-dummy-${process.pid}.txt`,
);
fs.writeFileSync(dummyPath, `${MARKER_A}\n${MARKER_B}\n`);
return dummyPath;
}
function pathspecFromFileArgs(dummyPath: string): string {
return `--pathspec-from-file=${dummyPath} --pathspec-file-nul`;
}
function expectBlockedWithoutDisclosure(
result: ReturnType<typeof runAction>,
) {
const combined = `${result.stdout}\n${result.stderr}`;
expect(result.status).not.toBe(0);
expect(result.outputs.committed).toBe('false');
expect(combined).toMatch(/not allowed/);
expect(combined).not.toContain(MARKER_A);
expect(combined).not.toContain(MARKER_B);
}
it('rejects add with pathspec_error_handling exitImmediately', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
add: pathspecFromFileArgs(dummyPath),
pathspec_error_handling: 'exitImmediately',
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('rejects add with default pathspec_error_handling ignore', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
add: pathspecFromFileArgs(dummyPath),
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('rejects add during dry_run at parse, not git add --dry-run', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
add: pathspecFromFileArgs(dummyPath),
dry_run: 'true',
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
const combined = `${result.stdout}\n${result.stderr}`;
expect(combined).not.toMatch(/fatal: pathspec/);
expect(combined).not.toMatch(/Dry run completed/i);
});
it('rejects remove with the same options', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
add: '',
remove: pathspecFromFileArgs(dummyPath),
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('rejects commit with the same options', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
writeFile(f.local, 'commit-args.txt', 'changed\n');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
commit: pathspecFromFileArgs(dummyPath),
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('rejects commit when a short-option cluster precedes the same options', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
writeFile(f.local, 'commit-cluster-args.txt', 'changed\n');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
commit: `-Sm --pathspec-from-file=${dummyPath} -Sm --pathspec-file-nul`,
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('rejects a YAML array element with the same options', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
add: JSON.stringify([pathspecFromFileArgs(dummyPath)]),
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
});
});