mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-29 13:41:45 +02:00
254 lines
11 KiB
JavaScript
254 lines
11 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 queue = require("../src/queue");
|
|
const db = require("../src/server/database");
|
|
const router = require("../src/server/routes/repository-private").default;
|
|
const refresh = router.stack.find(layer => layer.route?.path === "/:repoId/refresh").route.stack[0].handle;
|
|
|
|
describe("repository refresh and restoration", () => {
|
|
const restores = [];
|
|
function stub(object, key, value) {
|
|
const original = object[key];
|
|
restores.push(() => { object[key] = original; });
|
|
object[key] = value;
|
|
}
|
|
afterEach(() => { while (restores.length) restores.pop()(); });
|
|
function repository(status) {
|
|
const repo = new Repository(new Model({ repoId: "restore-me", status,
|
|
statusDate: new Date(),
|
|
owner: "507f1f77bcf86cd799439011",
|
|
source: { repositoryName: "owner/repo", branch: "main", commit: "saved-sha" },
|
|
options: { expirationMode: "never" },
|
|
}));
|
|
stub(utils, "getRepo", async () => repo);
|
|
stub(utils, "getUser", async () => ({ isAdmin: true, model: { id: "admin" } }));
|
|
stub(utils, "handleError", error => { throw error; });
|
|
return repo;
|
|
}
|
|
|
|
for (const status of ["preparing", "queue", "download", "removing", "expiring"]) {
|
|
it(`responds with a conflict during ${status} without starting another update`, async () => {
|
|
const repo = repository(status);
|
|
repo.updateIfNeeded = async () => { throw new Error("must not update"); };
|
|
let failure;
|
|
try { await refresh({}, {}); } catch (error) { failure = error; }
|
|
expect(failure?.message).to.equal("invalid_status");
|
|
expect(failure.httpStatus).to.equal(409);
|
|
});
|
|
}
|
|
|
|
it("checks ownership before returning an operation status", async () => {
|
|
repository("preparing");
|
|
stub(utils, "getUser", async () => ({ model: { id: "other" } }));
|
|
let failure;
|
|
try { await refresh({}, {}); } catch (error) { failure = error; }
|
|
expect(failure?.message).to.equal("not_authorized");
|
|
expect(failure.httpStatus).to.equal(403);
|
|
});
|
|
|
|
for (const commit of ["saved-sha", "new-sha"]) {
|
|
it(`rebuilds a removed repository at ${commit} while preserving its ID`, async () => {
|
|
const repo = repository("removed");
|
|
stub(db, "isConnected", false);
|
|
repo.getToken = async () => "test-token";
|
|
stub(github, "getRepositoryFromGitHub", async () => ({
|
|
fullName: "owner/repo", model: { defaultBranch: "main" },
|
|
branches: async () => [{ name: "main", commit }],
|
|
getCommitInfo: async () => ({ commit: {} }),
|
|
}));
|
|
repo.resetSate = async status => { repo.model.status = status; };
|
|
let added;
|
|
stub(queue, "downloadQueue", { add: async (...args) => { added = args; } });
|
|
let response;
|
|
await refresh({}, { json: body => { response = body; } });
|
|
expect(response.status).to.equal("preparing");
|
|
expect(repo.model.source.commit).to.equal(commit);
|
|
expect(repo.repoId).to.equal("restore-me");
|
|
expect(added[1]).to.deep.equal({ repoId: "restore-me" });
|
|
});
|
|
}
|
|
|
|
it("preserves a removed repository and reports a GitHub access failure", async () => {
|
|
const repo = repository("removed");
|
|
const failure = new Error("token_expired");
|
|
repo.getToken = async () => { throw failure; };
|
|
stub(queue, "downloadQueue", { add: async () => { throw new Error("must not enqueue"); } });
|
|
let caught;
|
|
try { await refresh({}, {}); } catch (error) { caught = error; }
|
|
expect(caught).to.equal(failure);
|
|
expect(repo.status).to.equal("removed");
|
|
expect(repo.model.source.commit).to.equal("saved-sha");
|
|
});
|
|
|
|
it("allows a stale download to be retried", async () => {
|
|
const repo = repository("download");
|
|
repo.model.statusDate = new Date(Date.now() - 6 * 60_000);
|
|
let retried = false;
|
|
repo.refresh = async () => { retried = true; };
|
|
await refresh({}, { json: () => {} });
|
|
expect(retried).to.equal(true);
|
|
});
|
|
|
|
function database(repo) {
|
|
const stored = repo.model.toObject();
|
|
stub(db, "isConnected", true);
|
|
const matches = filter => require("sift").default(filter)(stored);
|
|
stub(Model, "updateOne", (filter, update) => ({ exec: async () => {
|
|
if (!matches(filter)) return { matchedCount: 0 };
|
|
for (const [key, value] of Object.entries(update.$set || {})) {
|
|
const parts = key.split(".");
|
|
let target = stored;
|
|
while (parts.length > 1) { const part = parts.shift(); target = target[part] ||= {}; }
|
|
target[parts[0]] = value;
|
|
}
|
|
for (const key of Object.keys(update.$unset || {})) delete stored[key];
|
|
return { matchedCount: 1 };
|
|
} }));
|
|
stub(Model, "exists", async filter => matches(filter) ? { _id: stored._id } : null);
|
|
return stored;
|
|
}
|
|
|
|
it("claims a lease before GitHub work and rejects a concurrent refresh", async () => {
|
|
const first = repository("ready");
|
|
const second = new Repository(new Model(first.model.toObject()));
|
|
const stored = database(first);
|
|
let release, started;
|
|
const entered = new Promise(resolve => { started = resolve; });
|
|
first.updateIfNeeded = async () => {
|
|
started();
|
|
await new Promise(resolve => { release = resolve; });
|
|
};
|
|
second.updateIfNeeded = async () => { throw new Error("must not start another refresh"); };
|
|
const running = first.refresh();
|
|
await entered;
|
|
try {
|
|
let failure;
|
|
try { await second.refresh(); } catch (error) { failure = error; }
|
|
expect(failure?.message).to.equal("invalid_status");
|
|
expect(stored.status).to.equal("ready");
|
|
} finally { release(); await running; }
|
|
expect(stored).not.to.have.property("refreshToken");
|
|
});
|
|
|
|
it("releases a failed lease so reconnecting can be retried", async () => {
|
|
const repo = repository("removed");
|
|
const stored = database(repo);
|
|
const failure = new Error("token_expired");
|
|
repo.getToken = async () => { throw failure; };
|
|
let caught;
|
|
try { await repo.refresh(); } catch (error) { caught = error; }
|
|
expect(caught).to.equal(failure);
|
|
expect(stored).not.to.have.property("refreshToken");
|
|
expect(stored.status).to.equal("removed");
|
|
repo.updateIfNeeded = async () => {};
|
|
await repo.refresh();
|
|
});
|
|
|
|
it("reclaims an expired lease without letting the old request write or release it", async () => {
|
|
const old = repository("ready");
|
|
const stored = database(old);
|
|
const replacement = new Repository(new Model(old.model.toObject()));
|
|
let release, started;
|
|
const entered = new Promise(resolve => { started = resolve; });
|
|
old.updateIfNeeded = async () => {
|
|
started();
|
|
await new Promise(resolve => { release = resolve; });
|
|
await old.updateStatus("preparing");
|
|
};
|
|
const running = old.refresh().catch(error => error);
|
|
await entered;
|
|
stored.refreshUntil = new Date(0);
|
|
replacement.updateIfNeeded = async () => {
|
|
const replacementToken = stored.refreshToken;
|
|
release();
|
|
expect((await running).message).to.equal("invalid_status");
|
|
expect(stored.refreshToken).to.equal(replacementToken);
|
|
};
|
|
await replacement.refresh();
|
|
expect(stored.status).to.equal("ready");
|
|
});
|
|
|
|
for (const status of ["removing", "expiring", "archived"]) {
|
|
it(`does not overwrite ${status} requested during a GitHub lookup`, async () => {
|
|
const repo = repository("ready");
|
|
const stored = database(repo);
|
|
repo.getToken = async () => "token";
|
|
stub(github, "getRepositoryFromGitHub", async () => ({
|
|
fullName: "owner/repo", model: {},
|
|
branches: async () => {
|
|
stored.status = status;
|
|
return [{ name: "main", commit: "new-sha" }];
|
|
},
|
|
getCommitInfo: async () => ({ commit: {} }),
|
|
}));
|
|
repo.resetSate = async () => { throw new Error("must not delete cache"); };
|
|
stub(queue, "downloadQueue", { add: async () => { throw new Error("must not enqueue"); } });
|
|
let failure;
|
|
try { await repo.refresh(); } catch (error) { failure = error; }
|
|
expect(failure?.message).to.equal("invalid_status");
|
|
expect(stored.status).to.equal(status);
|
|
expect(stored.source.commit).to.equal("saved-sha");
|
|
});
|
|
}
|
|
|
|
for (const removedDuringReset of [false, true]) {
|
|
it(`rebuilds under a lease, removal during reset: ${removedDuringReset}`, async () => {
|
|
const repo = repository("removed");
|
|
const stored = database(repo);
|
|
repo.getToken = async () => "token";
|
|
stub(github, "getRepositoryFromGitHub", async () => ({
|
|
fullName: "owner/repo", model: {},
|
|
branches: async () => [{ name: "main", commit: "saved-sha" }],
|
|
getCommitInfo: async () => ({ commit: {} }),
|
|
}));
|
|
repo.resetSate = async status => {
|
|
await repo.updateStatus(status);
|
|
if (removedDuringReset) stored.status = "removing";
|
|
};
|
|
let added = false;
|
|
stub(queue, "downloadQueue", { add: async () => { added = true; } });
|
|
let failure;
|
|
try { await repo.refresh(); } catch (error) { failure = error; }
|
|
expect(added).to.equal(!removedDuringReset);
|
|
expect(stored.status).to.equal(removedDuringReset ? "removing" : "preparing");
|
|
expect(failure?.message).to.equal(removedDuringReset ? "invalid_status" : undefined);
|
|
expect(stored).not.to.have.property("refreshToken");
|
|
});
|
|
}
|
|
|
|
it("serves the dashboard polling URL with repository status", async () => {
|
|
const repo = repository("ready");
|
|
stub(db, "getRepository", async () => repo);
|
|
stub(require("../src/core/GitHubUtils"), "getToken", async () => { throw new Error("token_expired"); });
|
|
const express = require("express");
|
|
const app = express();
|
|
app.use((req, _res, next) => { req.isAuthenticated = () => true; next(); });
|
|
app.use("/api/repo", require("../src/server/routes/repository-public").default);
|
|
app.use("/api/repo", require("../src/server/routes/file").default);
|
|
app.use("/api/repo", router);
|
|
const server = await new Promise(resolve => {
|
|
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
});
|
|
try {
|
|
const response = await new Promise((resolve, reject) => {
|
|
require("http").get({ host: "127.0.0.1", port: server.address().port, path: "/api/repo/restore-me" }, res => {
|
|
let body = "";
|
|
res.on("data", chunk => { body += chunk; });
|
|
res.on("end", () => {
|
|
try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
|
|
catch (error) { reject(error); }
|
|
});
|
|
}).on("error", reject);
|
|
});
|
|
expect(response.status).to.equal(200);
|
|
expect(response.data.status).to.equal("ready");
|
|
expect(response.data.connectionError).to.equal("token_expired");
|
|
} finally { await new Promise(resolve => server.close(resolve)); }
|
|
});
|
|
|
|
});
|