fix: retain replacement reservations and bound expiration extensions

This commit is contained in:
Thomas Durieux committed 2026-11-02 21:27:11 +00:00
1 parent d7a5243054
commit afd7a8ff3b
5 files changed
+46 -4

No files matched your search

+3 -2
View File
@@ -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);
}
/**
+2 -1
View File
@@ -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<string, Date | string | null> = {
"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) => {
+23
View File
@@ -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");
+17
View File
@@ -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; } }));
+1 -1
View File
@@ -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 () => {