fix: reconnect resources after GitHub App reinstall

This commit is contained in:
Thomas Durieux committed 2026-10-07 10:34:55 +00:00
1 parent 2fb4b8fabd
commit 3140ad3c45
6 files changed
+92 -11

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const config = require("../src/config").default;
const app = require("../src/core/github-app");
const utils = require("../src/server/routes/route-utils");
const Repositories = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
const PullRequests = require("../src/core/model/anonymizedPullRequests/anonymizedPullRequests.model").default;
const { githubAppRouter } = require("../src/server/routes/github-app");
const migrate = githubAppRouter.stack.find(layer => layer.route?.path === "/connections/migrate").route.stack[0].handle;
for (const type of ["repository", "pull-request"]) {
describe(`${type} App reinstall recovery`, function () {
let restores, model, selected, writes, checks, owner;
function stub(object, key, value) {
const previous = object[key]; restores.push(() => { object[key] = previous; }); object[key] = value;
}
beforeEach(function () {
restores = []; writes = []; checks = []; owner = "owner";
model = { _id: "saved-resource", owner: "owner", status: "ready",
repoId: "published-url", pullRequestId: "published-url",
source: { repositoryName: "owner/private", repositoryFullName: "owner/private", commit: "saved-sha", pullRequestId: 7 },
githubAccess: { kind: "github-app", repositoryId: 42, installationId: 100, revision: "old" },
};
selected = { token: "fresh-token", binding: { kind: "github-app", repositoryId: 42, installationId: 200, revision: "new" } };
stub(config, "GITHUB_APP_ENABLED", true); stub(config, "GITHUB_APP_NEW_CONNECTIONS", true);
stub(utils, "getUser", async () => ({ id: owner }));
stub(utils, "handleError", error => { throw error; });
stub(app, "selectRepositoryAccess", async (id, name, connection) => {
expect([id, name, connection]).to.deep.equal(["owner", "owner/private", "github-app"]);
return selected;
});
stub(app, "githubRequest", async (url, token) => { checks.push({ url, token }); return {}; });
const Model = type === "repository" ? Repositories : PullRequests;
stub(Model, "findOne", async filter => filter.owner === model.owner ? model : null);
stub(Model, "updateOne", async (filter, update) => { writes.push({ filter, update }); return { modifiedCount: 1 }; });
});
afterEach(function () { while (restores.length) restores.pop()(); });
async function run(preview = false) {
let response;
await migrate({ body: { type, id: "published-url", connection: "github-app", preview } }, { json: body => { response = body; } });
return response;
}
async function fails(message) {
let error; try { await run(); } catch (caught) { error = caught; }
expect(error?.message).to.equal(message); expect(writes).to.have.length(0);
}
it("previews fresh installation access without changing the saved binding", async function () {
expect(await run(true)).to.deep.equal({ eligible: true, connection: "github-app" });
expect(writes).to.have.length(0); expect(model.githubAccess.installationId).to.equal(100);
});
it("replaces only the binding while preserving the URL, source and snapshot", async function () {
expect(await run()).to.deep.equal({ connection: "github-app" });
expect(writes).to.have.length(1);
expect(writes[0].update).to.deep.equal({ $set: { githubAccess: selected.binding } });
expect(writes[0].filter.githubAccess).to.deep.equal(model.githubAccess);
expect(writes[0].filter.source).to.deep.equal(model.source);
expect(checks).to.deep.equal([{ url: type === "repository" ? "/repos/owner/private/commits/saved-sha" : "/repos/owner/private/pulls/7", token: "fresh-token" }]);
});
it("rejects a replacement repository at the same name", async function () {
selected.binding.repositoryId = 99; await fails("connection_changed"); expect(checks).to.have.length(0);
});
it("requires access to the existing snapshot before saving", async function () {
stub(app, "githubRequest", async () => { throw new Error("github_app_access_required"); });
await fails("github_app_access_required");
});
it("does not let another user repair the binding", async function () {
owner = "other"; await fails("repo_not_found");
});
it("reports a concurrent change instead of overwriting it", async function () {
stub(type === "repository" ? Repositories : PullRequests, "updateOne", async () => ({ modifiedCount: 0 }));
await fails("connection_changed");
});
});
}
+8 -5
View File
@@ -155,20 +155,22 @@ describe("Vue 3 UI", function () {
expect(ui.errors).to.deep.equal([]);
});
it("previews a connection migration before switching and includes CSRF protection", async function () {
const resource = { type: "repository", id: "saved", name: "owner/private", connection: "oauth", status: "ready" };
for (const connection of ["oauth", "github-app"]) {
it(`previews and saves ${connection} access with CSRF protection`, async function () {
const action = connection === "github-app" ? "Reconnect read-only access" : "Switch to read-only access";
const resource = { type: "repository", id: "saved", name: "owner/private", connection, status: "ready" };
ui = await browser("/connections", {
"/api/user": { username: "owner" },
"/github/connections": { csrf: "csrf-value", appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: true,
"/github/connections": { csrf: "csrf-value", appEnabled: true, appConnected: true, oauthEnabled: true, oauthConnected: connection === "oauth",
installations: [{ id: 4, account: "owner" }], gistCount: 0, resources: [resource] },
"/github/connections/migrate": request => request.payload.preview ? { eligible: true } : { connection: "github-app" },
});
const button = label => [...ui.window.document.querySelectorAll("button")].find(node => node.textContent.includes(label));
expect(button("Switch to read-only access")).to.equal(undefined);
expect(button(action)).to.equal(undefined);
expect(ui.window.document.querySelector('a[href="/github/app/install?installationId=4"]')).not.to.equal(null);
button("Check read-only access").click();
await delay(30);
button("Switch to read-only access").click();
button(action).click();
await delay(30);
const changes = ui.requests.filter(r => r.url.pathname === "/github/connections/migrate");
expect(changes).to.have.length(2);
@@ -178,6 +180,7 @@ describe("Vue 3 UI", function () {
expect(changes[1].headers["X-CSRF-Token"]).to.equal("csrf-value");
expect(ui.errors).to.deep.equal([]);
});
}
for (const [appConnected, oauthConnected] of [[true, false], [false, true], [false, false], [true, true]]) {