Files
AgentHub/server/__tests__/git-credentials.test.js
T
nntrivi2001 f514602697 feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
2026-09-25 10:44:40 +07:00

124 lines
4.8 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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" }
);
});
});