const { expect } = require("chai"); require("ts-node/register/transpile-only"); const ConferenceModel = require("../src/core/model/conference/conferences.model") .default; const { applyConferenceForm, } = require("../src/server/routes/conference"); const { hasRepositorySourceChanged, shouldReactivateInactiveRepository, } = require("../src/server/routes/repository-private"); const { processRemoveRepository, createRemoveRepositoryProcessor, } = require("../src/queue/processes/removeRepository"); const { processRemoveCache, createRemoveCacheProcessor, } = require("../src/queue/processes/removeCache"); const { addRemovalJob } = require("../src/queue"); const { repositoryMaintenanceQuery, } = require("../src/server/schedule"); const Repository = require("../src/core/Repository").default; const AnonymizedRepositoryModel = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default; const queueModule = require("../src/queue"); const routeUtils = require("../src/server/routes/route-utils"); const UserModel = require("../src/core/model/users/users.model").default; describe("removal recovery", function () { let originals; beforeEach(function () { originals = { distinct: UserModel.distinct, queue: queueModule.removeQueue, find: AnonymizedRepositoryModel.find, findOneAndUpdate: AnonymizedRepositoryModel.findOneAndUpdate, getRepo: routeUtils.getRepo, getUser: routeUtils.getUser, handleError: routeUtils.handleError, }; UserModel.distinct = () => ({ exec: async () => [] }); }); afterEach(function () { UserModel.distinct = originals.distinct; queueModule.removeQueue = originals.queue; AnonymizedRepositoryModel.find = originals.find; AnonymizedRepositoryModel.findOneAndUpdate = originals.findOneAndUpdate; routeUtils.getRepo = originals.getRepo; routeUtils.getUser = originals.getUser; routeUtils.handleError = originals.handleError; }); it("retries an older ban job that failed before marking the repository removing", async function () { let queued = false; UserModel.distinct = () => ({ exec: async () => ["banned-owner"] }); queueModule.removeQueue = { getJobs: async () => [{ data: { repoId: "repo" }, timestamp: 1000, remove: async () => { throw new Error("must not discard ban"); } }], getJob: async () => undefined, add: async () => { queued = true; }, }; AnonymizedRepositoryModel.findOneAndUpdate = filter => ({ collation() { return this; }, exec: async () => require("sift").default(filter)({ repoId: "repo", owner: "banned-owner", status: "ready" }) ? { repoId: "repo" } : null, }); AnonymizedRepositoryModel.find = () => ({ lean: async () => [] }); await queueModule.recoverStuckRemoving(); expect(queued).to.equal(true); }); for (const status of ["ready", "preparing", "removed", "error"]) { it(`discards an old failed removal after restoration to ${status}`, async function () { let discarded = false; let queued = false; const job = { data: { repoId: "repo-1" }, timestamp: 1000, remove: async () => { discarded = true; }, }; queueModule.removeQueue = { getJobs: async () => [job], getJob: async () => undefined, add: async () => { queued = true; }, }; AnonymizedRepositoryModel.findOneAndUpdate = (filter) => ({ collation() { return this; }, exec: async () => { // A restored snapshot was created after the failed deletion request. const eligible = require("sift").default(filter)({ repoId: "repo-1", status, anonymizeDate: new Date(2000), }); return eligible ? { repoId: "repo-1" } : null; }, }); AnonymizedRepositoryModel.find = () => ({ lean: async () => [] }); await queueModule.recoverStuckRemoving(); expect(discarded).to.equal(true); expect(queued).to.equal(false); }); } for (const status of ["error", "removing"]) { it(`retries a failed removal still in ${status}`, async function () { const repo = { repoId: "repo-1", status, anonymizeDate: new Date(500), statusDate: new Date(3000), }; let added; const job = { data: { repoId: repo.repoId }, timestamp: 1000, finishedOn: 2000 }; queueModule.removeQueue = { getJobs: async () => [job], getJob: async () => undefined, add: async (...args) => { added = args; }, }; AnonymizedRepositoryModel.findOneAndUpdate = (filter, update) => ({ collation() { return this; }, exec: async () => { if (!require("sift").default(filter)(repo)) return null; Object.assign(repo, update.$set); return repo; }, }); AnonymizedRepositoryModel.find = () => ({ lean: async () => [] }); await queueModule.recoverStuckRemoving(); expect(repo.status).to.equal("removing"); expect(added[1]).to.deep.equal({ repoId: repo.repoId }); }); } it("preserves deletion across a failed request enqueue and recovery enqueue", async function () { const repo = new Repository(new AnonymizedRepositoryModel({ repoId: "repo-1", status: "ready", owner: "507f1f77bcf86cd799439011", options: { expirationMode: "never" }, source: {}, })); routeUtils.getRepo = async () => repo; routeUtils.getUser = async () => ({ model: { id: "admin" }, isAdmin: true }); let responseError; routeUtils.handleError = (error) => { responseError = error; }; const failure = new Error("Redis unavailable"); queueModule.removeQueue = { getJobs: async () => [], getJob: async () => undefined, add: async () => { throw failure; }, }; const router = require("../src/server/routes/repository-private").default; const handler = router.stack.find((layer) => layer.route?.path === "/:repoId/" && layer.route.methods.delete ).route.stack[0].handle; await handler({ params: { repoId: repo.repoId } }, {}); expect(responseError).to.equal(failure); expect(repo.status).to.equal("removing"); let publicError; try { await repo.check(); } catch (error) { publicError = error; } expect(publicError.message).to.equal("repository_expired"); AnonymizedRepositoryModel.find = (filter) => ({ lean: async () => repo.status === filter.status ? [repo.model] : [], }); await queueModule.recoverStuckRemoving(); expect(repo.status).to.equal("removing"); let added; queueModule.removeQueue.add = async (...args) => { added = args; }; await queueModule.recoverStuckRemoving(); expect(added[1]).to.deep.equal({ repoId: repo.repoId }); }); }); describe("conference edits", function () { const form = { name: "Updated", startDate: "2026-01-01", endDate: "2026-02-01", url: "https://example.test", options: { expirationMode: "never" }, }; it("preserves existing repository membership", function () { const existing = [{ id: "507f1f77bcf86cd799439011", addDate: new Date() }]; const model = new ConferenceModel({ repositories: existing }); applyConferenceForm(model, form, false); expect(model.repositories).to.have.length(1); expect(model.repositories[0].id.toString()).to.equal(existing[0].id); }); it("initializes repository membership for a new conference", function () { const model = new ConferenceModel(); applyConferenceForm(model, form, true); expect(model.repositories).to.deep.equal([]); }); }); describe("repository update source detection", function () { const model = { source: { commit: "abc123", branch: "main", repositoryName: "owner/repo", }, }; it("does not redownload for an option-only edit", function () { expect( hasRepositorySourceChanged(model, { fullName: "owner/repo", source: { commit: "abc123", branch: "main" }, options: { image: false }, }) ).to.equal(false); }); it("redownloads when the commit, branch, or repository changes", function () { expect( hasRepositorySourceChanged(model, { fullName: "owner/repo", source: { commit: "def456", branch: "main" }, }) ).to.equal(true); expect( hasRepositorySourceChanged(model, { fullName: "owner/repo", source: { commit: "abc123", branch: "next" }, }) ).to.equal(true); expect( hasRepositorySourceChanged(model, { fullName: "other/repo", source: { commit: "abc123", branch: "main" }, }) ).to.equal(true); }); it("rebuilds an expired repository when its expiration is in the future", function () { const now = new Date("2026-08-20T00:00:00.000Z"); expect( shouldReactivateInactiveRepository( { status: "expired", options: { expirationMode: "redirect", expirationDate: new Date("2027-05-01T03:57:53.395Z"), }, }, now ) ).to.equal(true); }); it("does not rebuild an expired repository with a stale expiration", function () { const now = new Date("2026-08-20T00:00:00.000Z"); expect( shouldReactivateInactiveRepository( { status: "expired", options: { expirationMode: "redirect", expirationDate: new Date("2026-01-01T00:00:00.000Z"), }, }, now ) ).to.equal(false); }); it("rebuilds an expired repository configured never to expire", function () { expect( shouldReactivateInactiveRepository({ status: "expired", options: { expirationMode: "never" }, }) ).to.equal(true); }); it("rebuilds a removed repository when its expiration is in the future", function () { const now = new Date("2026-08-20T00:00:00.000Z"); expect( shouldReactivateInactiveRepository( { status: "removed", options: { expirationMode: "remove", expirationDate: new Date("2027-01-31T04:18:02.444Z"), }, }, now ) ).to.equal(true); }); }); describe("removal workers", function () { const job = { data: { repoId: "repo-1" } }; it("rejects the repository job after recording a removal error", async function () { const statuses = []; const failure = new Error("storage unavailable"); const repo = { updateStatus: async (status, message) => statuses.push([status, message]), remove: async () => { throw failure; }, }; let caught; try { await processRemoveRepository(job, { connect: async () => undefined, getRepository: async () => repo, }); } catch (error) { caught = error; } expect(caught).to.equal(failure); expect(statuses[statuses.length - 1][1]).to.equal(failure.message); }); it("records errors that happen before repository removal starts", async function () { const statuses = []; const failure = new Error("status write failed"); let statusCalls = 0; const repo = { updateStatus: async (status, message) => { statusCalls++; statuses.push([status, message]); if (statusCalls === 1) throw failure; }, remove: async () => undefined, }; let caught; try { await processRemoveRepository(job, { connect: async () => undefined, getRepository: async () => repo, }); } catch (error) { caught = error; } expect(caught).to.equal(failure); expect(statuses[statuses.length - 1][1]).to.equal(failure.message); }); it("does not treat the BullMQ lock token as a database dependency", async function () { const statuses = []; const database = { connect: async () => undefined, getRepository: async () => ({ updateStatus: async (status) => statuses.push(status), remove: async () => undefined, }), }; const processor = createRemoveRepositoryProcessor(database); await processor(job, "bullmq-lock-token"); expect(statuses).to.include("removing"); }); it("does not add a duplicate when a removal job is live", async function () { let additions = 0; const queue = { getJob: async () => ({ getState: async () => "active", remove: async () => undefined, }), add: async () => { additions++; }, }; expect(await addRemovalJob("repo-1", queue)).to.equal(false); expect(additions).to.equal(0); }); it("replaces a terminal removal job with a retryable job", async function () { let removed = false; let added; const queue = { getJob: async () => ({ getState: async () => "failed", remove: async () => { removed = true; }, }), add: async (...args) => { added = args; }, }; expect(await addRemovalJob("repo-1", queue)).to.equal(true); expect(removed).to.equal(true); expect(added[0]).to.equal("repo-1"); expect(added[1]).to.deep.equal({ repoId: "repo-1" }); expect(added[2].jobId).to.equal("repo-repo-1"); expect(added[2].attempts).to.equal(3); expect(added[2].removeOnFail).to.deep.equal({ count: 1000 }); }); it("rejects cache jobs when cache removal fails", async function () { const failure = new Error("storage unavailable"); let caught; try { await processRemoveCache(job, { connect: async () => undefined, getRepository: async () => ({ removeCache: async () => { throw failure; }, }), }); } catch (error) { caught = error; } expect(caught).to.equal(failure); }); it("keeps the BullMQ lock token out of cache worker injection", async function () { let removed = false; const processor = createRemoveCacheProcessor({ connect: async () => undefined, getRepository: async () => ({ removeCache: async () => { removed = true; }, }), }); await processor(job, "bullmq-lock-token"); expect(removed).to.equal(true); }); }); describe("repository expiration maintenance", function () { it("selects due repositories regardless of their cache reset flag", function () { const now = new Date("2026-08-20T00:00:00.000Z"); const query = repositoryMaintenanceQuery(now); expect(query).not.to.have.property("isReseted"); expect(query.$or[0]).to.deep.equal({ "options.expirationMode": { $in: ["redirect", "remove"] }, "options.expirationDate": { $lte: now }, }); }); it("marks a cache as present again after content is restored", async function () { const model = new AnonymizedRepositoryModel({ repoId: "repo-cache-state", owner: "507f1f77bcf86cd799439011", isReseted: true, status: "ready", options: { terms: [], expirationMode: "never" }, source: { type: "GitHubStream", repositoryName: "owner/repo" }, }); const repo = new Repository(model); await repo.markCachePresent(); expect(repo.model.isReseted).to.equal(false); }); });