Files
AgentHub/server/__tests__/git-credentials-route.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

96 lines
3.5 KiB
JavaScript

/**
* @file Route tests for git credentials (issue #45): /api/git-credentials
* never returns a password and refuses a cross-origin write, and a lane's
* fetch against a remote that wants a login answers 409 EGITAUTH with the
* host the git bar's login form needs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = 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 http = require("node:http");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-route-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.HOME = ROOT;
const { createApp } = require("../index");
let app;
let remote;
let base;
let host;
function request(method, p, body, headers = {}) {
return fetch(`${base}${p}`, {
method,
headers: { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
}
before(async () => {
remote = http.createServer((_req, res) => {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
res.end();
});
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
host = `127.0.0.1:${remote.address().port}`;
app = http.createServer(createApp());
await new Promise((r) => app.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${app.address().port}`;
});
after(async () => {
await new Promise((r) => app.close(r));
await new Promise((r) => remote.close(r));
});
describe("/api/git-credentials", () => {
it("saves, lists without the secret, and deletes", async () => {
const saved = await request("POST", "/api/git-credentials", {
protocol: "https",
host: "git.example.test",
username: "vi",
password: "super-secret-token",
});
assert.equal(saved.status, 200);
const list = await request("GET", "/api/git-credentials");
assert.equal(list.body.items[0].hasToken, true);
assert.ok(!JSON.stringify(list.body).includes("super-secret-token"));
const del = await request("DELETE", "/api/git-credentials/https/git.example.test");
assert.deepEqual(del.body.items, []);
});
it("refuses a cross-origin write", async () => {
const r = await request(
"POST",
"/api/git-credentials",
{ protocol: "https", host: "h.test", username: "u", password: "p" },
{ Origin: "https://evil.example" }
);
assert.equal(r.status, 403);
});
});
describe("POST /api/lanes/:id/git/fetch", () => {
it("answers 409 EGITAUTH with the host when the remote wants a login", async () => {
const repo = path.join(ROOT, "repo");
execFileSync("git", ["init", "-q", repo]);
execFileSync("git", ["-C", repo, "remote", "add", "origin", `http://${host}/o/r.git`]);
const lane = await request("POST", "/api/lanes/ensure", { cwd: repo, tool: "claude" });
const r = await request("POST", `/api/lanes/${lane.body.lane.id}/git/fetch`, {});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "EGITAUTH");
assert.equal(r.body.error.auth.host, host);
assert.equal(r.body.error.auth.rejected, false);
});
});