Files
anonymous_github/test/repository-reconnect.test.js
T

160 lines
8.6 KiB
JavaScript

const { expect } = require("chai");
require("ts-node/register/transpile-only");
const Repository = require("../src/core/Repository").default;
const Model = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
const utils = require("../src/server/routes/route-utils");
const github = require("../src/core/source/GitHubRepository");
const app = require("../src/core/github-app");
const tokens = require("../src/core/GitHubUtils");
const db = require("../src/server/database");
const queue = require("../src/queue");
const router = require("../src/server/routes/repository-private").default;
const handler = (path, method = "get") => router.stack.find(layer =>
layer.route?.path === path && layer.route.methods[method]).route.stack[0].handle;
describe("reconnecting a recreated repository", () => {
const restores = [];
function stub(object, key, value) {
const original = object[key]; restores.push(() => { object[key] = original; }); object[key] = value;
}
let repo, user, fresh, selected, writes, removals, jobs, commitChecks;
beforeEach(() => {
repo = new Repository(new Model({ repoId: "recreated", status: "ready",
owner: "507f1f77bcf86cd799439011",
githubAccess: { kind: "github-app", publicRead: true, repositoryId: 1380842178, revision: "old" },
source: { repositoryName: "owner/repo", repositoryId: "old-record", branch: "main", commit: "deadbeef" },
options: { terms: ["author"], expirationMode: "never" },
}));
user = { id: repo.owner.id, model: { id: repo.owner.id } };
selected = { token: "fresh-token", binding: { kind: "github-app", publicRead: true, repositoryId: 1396237353, revision: "fresh" } };
writes = []; removals = []; jobs = []; commitChecks = [];
fresh = { id: "gh_1396237353", model: { id: "new-record" }, fullName: "owner/repo",
toJSON: () => ({ externalId: "gh_1396237353" }),
branches: async () => [{ name: "main", commit: "abcdef" }],
readme: async () => "replacement readme",
getCommitInfo: async sha => { commitChecks.push(sha); return { commit: { committer: { date: "2026-09-29T00:00:00Z" } } }; },
};
stub(utils, "getRepo", async () => repo);
stub(utils, "getUser", async () => user);
stub(utils, "handleError", error => { throw error; });
stub(db, "getRepository", async () => repo);
stub(app, "selectRepositoryAccess", async (owner, name, kind) => {
expect([owner, name, kind]).to.deep.equal([repo.owner.id, "owner/repo", "github-app"]);
return selected;
});
stub(app, "boundAppToken", async () => { throw new Error("old binding is inaccessible"); });
stub(tokens, "getToken", async () => { throw new Error("old binding is inaccessible"); });
stub(github, "getRepositoryFromGitHub", async options => {
expect(options.accessToken).to.equal("fresh-token");
expect(options.force).to.equal(true);
expect(options.repositoryID).to.equal(undefined);
return fresh;
});
stub(Model, "updateOne", (filter, update) => ({ exec: async () => {
writes.push({ filter, update }); return { matchedCount: 1 };
} }));
repo.remove = async options => { removals.push(options); };
repo.updateStatus = async status => { repo.model.status = status; };
stub(queue, "downloadQueue", { add: async (...args) => { jobs.push(args); } });
});
afterEach(() => { while (restores.length) restores.pop()(); });
function preview(query = {}) {
return { params: { owner: "owner", repo: "repo" }, query: {
anonymizedRepoId: repo.repoId, repositoryID: "old-record", reconnect: "1", ...query,
} };
}
function form(extra = {}) {
return { body: { repoId: repo.repoId, fullName: "owner/repo", reconnectRepositoryId: "gh_1396237353",
source: { branch: "main", commit: "abcdef" }, terms: ["author"], options: { expirationMode: "never" }, ...extra } };
}
async function failure(work, message) {
let error;
try { await work(); } catch (caught) { error = caught; }
expect(error?.message).to.equal(message);
expect(writes).to.have.length(0);
expect(removals).to.have.length(0);
expect(jobs).to.have.length(0);
}
for (const path of ["/:owner/:repo/", "/:owner/:repo/branches", "/:owner/:repo/readme"]) {
it(`previews ${path} with fresh access without changing the anonymization`, async () => {
let result;
await handler(path)(preview(), { json: value => { result = value; }, send: value => { result = value; } });
expect(result).not.to.equal(undefined);
expect(repo.model.githubAccess.repositoryId).to.equal(1380842178);
expect(writes).to.have.length(0);
});
it(`keeps the bound identity for ordinary ${path} requests`, async () => {
await failure(() => handler(path)(preview({ reconnect: undefined }), {}), "old binding is inaccessible");
});
it(`denies a non-owner reconnect preview at ${path}`, async () => {
user = { id: "admin", isAdmin: true };
await failure(() => handler(path)(preview(), {}), "not_owner");
});
}
it("rebinds the source and downloads the replacement even when the old commit no longer exists", async () => {
await handler("/:repoId/", "post")(form(), { json: () => {} });
expect(commitChecks).to.deep.equal(["abcdef"]);
expect(writes).to.have.length(1);
const saved = writes[0];
expect(saved.filter["githubAccess.revision"]).to.equal("old");
expect(saved.update.$set.githubAccess.repositoryId).to.equal(1396237353);
expect(saved.update.$set.githubAccess.revision).not.to.equal("old");
expect(saved.update.$set.source.repositoryId).to.equal("new-record");
expect(saved.update.$set.source.commit).to.equal("abcdef");
expect(saved.update.$set.source.commitDate).to.deep.equal(new Date("2026-09-29T00:00:00Z"));
expect(removals).to.deep.equal([{ accessRevision: "old" }]);
expect(jobs[0][1]).to.deep.equal({ repoId: "recreated" });
});
it("rebuilds when only the repository identity changed", async () => {
await handler("/:repoId/", "post")(form({ source: { branch: "main", commit: "deadbeef" } }), { json: () => {} });
expect(jobs).to.have.length(1);
});
it("does not reconnect implicitly on an ordinary edit", async () => {
await failure(() => handler("/:repoId/", "post")(form({ reconnectRepositoryId: undefined }), {}), "old binding is inaccessible");
});
it("rejects a non-owner save", async () => {
user = { id: "admin", isAdmin: true };
await failure(() => handler("/:repoId/", "post")(form(), {}), "not_owner");
});
it("rejects a coauthor's reconnect preview and save", async () => {
repo.model.coauthors = [{ username: "coauthor", githubId: "42" }];
user = { id: "other", username: "coauthor", model: { id: "other", externalIDs: { github: "42" } } };
await failure(() => handler("/:owner/:repo/branches")(preview(), {}), "not_owner");
await failure(() => handler("/:repoId/", "post")(form(), {}), "not_owner");
});
it("does not remove files if the connection changed during validation", async () => {
repo.remove = Repository.prototype.remove.bind(repo);
repo.resetSate = async () => { throw new Error("must not remove files"); };
stub(Model, "updateOne", async () => ({ matchedCount: 0 }));
await failure(() => handler("/:repoId/", "post")(form(), {}), "connection_changed");
});
it("rejects replacement after the owner previewed the repository", async () => {
fresh.id = "gh_999999";
await failure(() => handler("/:repoId/", "post")(form(), {}), "connection_changed");
});
it("rejects a binding that differs from the resolved repository", async () => {
selected.binding.repositoryId = 999999;
await failure(() => handler("/:repoId/", "post")(form(), {}), "connection_changed");
});
it("preserves the snapshot when new access is denied", async () => {
stub(app, "selectRepositoryAccess", async () => { throw new Error("github_app_access_required"); });
await failure(() => handler("/:repoId/", "post")(form(), {}), "github_app_access_required");
});
it("rejects a missing replacement branch", async () => {
fresh.branches = async () => [];
await failure(() => handler("/:repoId/", "post")(form(), {}), "branch_not_specified");
});
it("preserves the snapshot when the selected commit is inaccessible", async () => {
fresh.getCommitInfo = async () => { throw new Error("commit_not_found"); };
await failure(() => handler("/:repoId/", "post")(form(), {}), "commit_not_found");
});
for (const status of ["preparing", "queue", "download", "removing", "expiring"]) {
it(`does not reconnect during ${status}`, async () => {
repo.model.status = status;
await failure(() => handler("/:repoId/", "post")(form(), {}), "invalid_status");
});
}
});