diff --git a/src/core/Repository.ts b/src/core/Repository.ts index a79082a..69abb86 100644 --- a/src/core/Repository.ts +++ b/src/core/Repository.ts @@ -978,7 +978,7 @@ export default class Repository { /** * Remove the repository */ - async remove(expected?: { accessRevision?: string }) { + async remove(expected?: { accessRevision?: string; replacement?: boolean }) { if (expected) { // Claim the lifecycle before deleting files; migration rejects REMOVING. this.assertNotArchived(); @@ -992,7 +992,8 @@ export default class Repository { await this.updateStatus(RepositoryStatus.REMOVING); } await this.resetSate(); - await this.updateStatus(RepositoryStatus.REMOVED); + // A source replacement retains its reservation throughout cache retirement. + await this.updateStatus(expected?.replacement ? RepositoryStatus.PREPARING : RepositoryStatus.REMOVED); } /** diff --git a/src/server/routes/repository-private.ts b/src/server/routes/repository-private.ts index 506ef1a..b22d45b 100644 --- a/src/server/routes/repository-private.ts +++ b/src/server/routes/repository-private.ts @@ -205,6 +205,7 @@ router.post( const newExpiration = extendExpirationDate( repo.model.options.expirationDate ); + validateBillingExpiration({ ...repo.model.options, expirationDate: newExpiration }); const reactivating = repo.status === RepositoryStatus.EXPIRED; const updates: Record = { "options.expirationDate": newExpiration, @@ -599,7 +600,7 @@ router.post( await reserveRepository(String(repo.model.owner), repo.repoId, files.reduce((sum, file) => sum + (file.size || 0), 0), repoUpdate.conference); } - await repo.remove({ accessRevision: previousAccessRevision }); + await repo.remove({ accessRevision: previousAccessRevision, replacement: true }); } const removeRepoFromConference = async (conferenceID: string) => { diff --git a/test/billing-integration.test.js b/test/billing-integration.test.js index ae21b68..2094b1b 100644 --- a/test/billing-integration.test.js +++ b/test/billing-integration.test.js @@ -85,6 +85,29 @@ async function rejects(promise, message) { expect(body.file.used).to.equal(1); } finally { utils.getUser = originalUser; } }); + for (const replacement of [true, false]) { + it(`retains a reservation during retirement and ${replacement ? "replacement" : "releases it after permanent removal"}`, async () => { + const Repository = require("../src/core/Repository").default; + const db = require("../src/server/database"); + const wasConnected = db.isConnected; + config.FREE_AUTHOR_REPOSITORIES = 1; + const model = await Repo.create({ repoId: "published", owner, billingManaged: true, status: "ready", size: { storage: 10 } }); + await reserveRepository(owner, "published", 20); + const repo = new Repository(model); + repo.resetSate = async () => { + expect((await Repo.findOne({ repoId: "published" })).status).to.equal("removing"); + await rejects(reserveRepository(owner, "competitor", 1), "quota_exceeded"); + }; + db.isConnected = true; + try { await repo.remove({ replacement }); } + finally { db.isConnected = wasConnected; } + expect((await Repo.findOne({ repoId: "published" })).status).to.equal(replacement ? "preparing" : "removed"); + if (replacement) { + await rejects(reserveRepository(owner, "competitor", 1), "quota_exceeded"); + expect((await BillingQuota.findById(`user:${owner}`)).allocations[0].bytes).to.equal(20); + } else await reserveRepository(owner, "competitor", 1); + }); + } it("counts legacy repositories and releases removed repositories", async () => { await Repo.create(["one", "two", "three"].map(repoId => ({ repoId, owner, status: "ready", size: { storage: 10 } }))); await rejects(reserveRepository(owner, "four", 1), "quota_exceeded"); diff --git a/test/repository-activity.test.js b/test/repository-activity.test.js index 787418d..3a95e55 100644 --- a/test/repository-activity.test.js +++ b/test/repository-activity.test.js @@ -124,6 +124,23 @@ describe("repository activity dates", () => { else expect(saved.$unset).to.have.property("billingNeverExpiresUntil", 1); }); } + it("rejects repeated extensions beyond one year before changing stored settings", async () => { + const repo = repository(); auth(repo); + stub(require("../src/config").default, "BILLING_ENABLED", true); + repo.model.options.expirationMode = "remove"; + repo.model.options.expirationDate = new Date(); + let writes = 0; + stub(Model, "updateOne", () => ({ exec: async () => { writes++; return { matchedCount: 1 }; } })); + const extend = handler("repository-private", "/:repoId/extend", "post"); + await extend({}, { json: () => {} }); + await extend({}, { json: () => {} }); + const savedDate = repo.model.options.expirationDate; + let error; + try { await extend({}, { json: () => {} }); } catch (caught) { error = caught; } + expect(error?.message).to.equal("invalid_billing_expiration"); + expect(writes).to.equal(2); + expect(repo.model.options.expirationDate).to.deep.equal(savedDate); + }); it("dates a dashboard expiration extension", async () => { const repo = repository(); auth(repo); let saved; stub(Model, "updateOne", (_filter, update) => ({ exec: async () => { saved = update.$set; } })); diff --git a/test/repository-reconnect.test.js b/test/repository-reconnect.test.js index dcac1aa..794306a 100644 --- a/test/repository-reconnect.test.js +++ b/test/repository-reconnect.test.js @@ -106,7 +106,7 @@ describe("reconnecting a recreated repository", () => { expect(saved.update.$set.status).to.equal("preparing"); expect(saved.update.$set.statusDate).to.be.instanceOf(Date); expect(saved.update.$set.source.commitDate).to.deep.equal(new Date("2026-09-29T00:00:00Z")); - expect(removals).to.deep.equal([{ accessRevision: "old" }]); + expect(removals).to.deep.equal([{ accessRevision: "old", replacement: true }]); expect(jobs[0][1]).to.deep.equal({ repoId: "recreated" }); }); it("rebuilds when only the repository identity changed", async () => {