/** * @file Tests for AgentHub's own git credential store and the credential * helper that hands it to git: save/list/remove at 0600, a list that never * carries the secret, auth-failure detection, and a REAL `git credential fill` * run through the helper — stored and use-once. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-")); process.env.CLAUDE_HOME = path.join(ROOT, ".claude"); const creds = require("../lib/git-credentials"); function fill(dir, cfg, env = {}) { // Exactly what git-ops passes, then git's own credential lookup. const out = execFileSync("git", [...cfg.args, "credential", "fill"], { cwd: dir, input: "protocol=https\nhost=git.example.test\n\n", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...cfg.env, ...env }, encoding: "utf8", }); return Object.fromEntries( out .trim() .split("\n") .map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]) ); } beforeEach(() => fs.rmSync(path.join(ROOT, ".claude"), { recursive: true, force: true })); describe("git credential store", () => { it("saves one record per protocol+host at 0600 and never lists the secret", () => { creds.save({ protocol: "https", host: "Git.Example.Test", username: "a", password: "t1" }); creds.save({ protocol: "https", host: "git.example.test", username: "b", password: "t2" }); const list = creds.list(); assert.equal(list.length, 1); assert.equal(list[0].host, "git.example.test"); assert.equal(list[0].username, "b"); assert.equal(list[0].hasToken, true); assert.ok(!JSON.stringify(list).includes("t2")); assert.equal(fs.statSync(creds.credentialsFile()).mode & 0o777, 0o600); assert.equal(creds.remove("https", "git.example.test"), true); assert.deepEqual(creds.list(), []); }); it("refuses a host that is not a host", () => { for (const host of ["", "a/b", "x y", "../etc", "h\nost"]) { assert.throws(() => creds.save({ protocol: "https", host, username: "u", password: "p" }), { code: "EBADHOST", }); } assert.throws(() => creds.save({ protocol: "ssh", host: "h", username: "u", password: "p" }), { code: "EBADHOST", }); }); it("recognises git's auth failures and names the host", () => { assert.deepEqual( creds.authFailure( "fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled" ), { protocol: "https", host: "git.smartgift.io.vn" } ); assert.deepEqual( creds.authFailure( "remote: Invalid username or password.\nfatal: Authentication failed for 'http://h.local:3000/o/r.git/'" ), { protocol: "http", host: "h.local:3000" } ); assert.equal(creds.authFailure("! [rejected] main -> main (fetch first)"), null); }); }); describe("git credential helper, through real git", () => { const repo = fs.mkdtempSync(path.join(ROOT, "repo-")); execFileSync("git", ["init", "-q", repo]); execFileSync("git", ["-C", repo, "remote", "add", "origin", "https://git.example.test/o/r.git"]); it("answers from the store, and only our helper is asked", async () => { creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" }); const cfg = await creds.gitConfigFor(repo); assert.equal(cfg.args[1], "credential.helper="); const got = fill(repo, cfg); assert.equal(got.username, "u1"); assert.equal(got.password, "stored"); assert.ok(!cfg.args.join(" ").includes("stored"), "secret must not reach argv"); }); it("prefers a use-once credential, which never touches the store", async () => { creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" }); const once = { protocol: "https", host: "git.example.test", username: "u2", password: "once" }; const cfg = await creds.gitConfigFor(repo, once); const got = fill(repo, cfg); assert.equal(got.password, "once"); assert.equal(creds.list()[0].username, "u1"); assert.ok(!cfg.args.join(" ").includes("once")); }); it("changes nothing when AgentHub holds nothing for the repo's hosts", async () => { const cfg = await creds.gitConfigFor(repo); assert.deepEqual(cfg, { args: [], env: {} }); }); it("refuses a use-once credential for a host the repo does not use", async () => { await assert.rejects( creds.gitConfigFor(repo, { protocol: "https", host: "evil.test", username: "u", password: "p", }), { code: "EBADHOST" } ); }); });