From 4dc4454459be7c3c62db9bc09706c1a337d94eae Mon Sep 17 00:00:00 2001 From: Thomas Durieux <5577568+tdurieux@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:39:17 +0000 Subject: [PATCH] fix: protect reactivation claims and retain historical purchase prices --- docs/paid-hosting.md | 7 ++- src/core/billing/models.ts | 6 +- src/core/billing/quotas.ts | 9 ++- src/core/billing/stripe.ts | 19 +++++- src/server/routes/repository-private.ts | 20 ++++++ test/billing-integration.test.js | 84 ++++++++++++++++++++++++- test/billing-reliability.test.js | 20 +++++- test/repository-activity.test.js | 13 ++++ 8 files changed, 165 insertions(+), 13 deletions(-) diff --git a/docs/paid-hosting.md b/docs/paid-hosting.md index b215e38..668ea9a 100644 --- a/docs/paid-hosting.md +++ b/docs/paid-hosting.md @@ -189,7 +189,7 @@ HTTP endpoints. ### Restore and launch checks Back up `billingaccounts`, `billinggrants`, `billingquotas`, `billinglocks`, `billingevents`, -`billingrevocations`, and `billingaudits` with repository data. Test a restore into an +`billingrevocations`, `billingprices`, and `billingaudits` with repository data. Test a restore into an isolated environment with sandbox credentials. Keep destructive repository maintenance stopped during restore, discover customer mappings, reconcile restored owners, drain queued events, and inspect health before re-enabling normal scheduling. Never connect a @@ -263,3 +263,8 @@ Reservations remain conservative across generations and failed fills and require operator reconciliation to release unused capacity. This also covers public raw downloads and expanded submodule content. Existing cached content is not retroactively measured by this write-time guard. + +Accepted one-time prices are stored in `billingprices` when checkout starts and when +existing purchases are fulfilled. Hiding an offer by removing its configured price +does not remove this mapping or invalidate paid grants. Include the mapping in backups +so reconciliation can reconstruct grants for retired offers and process their refunds. diff --git a/src/core/billing/models.ts b/src/core/billing/models.ts index 9095c55..24b1199 100644 --- a/src/core/billing/models.ts +++ b/src/core/billing/models.ts @@ -11,12 +11,13 @@ export const BillingGrant = model("billingGrant", new Schema({ _id: String, owner: { type: String, index: true }, scope: { type: String, index: true }, planId: String, customerId: String, subscriptionId: String, paymentIntentId: { type: String, index: true }, status: String, until: Date, cancelAtPeriodEnd: Boolean, + priceId: String, revision: { type: Number, default: 0 }, revoked: { type: Boolean, default: false }, revocationReason: String, revokedAt: Date, })); export const BillingQuota = model("billingQuota", new Schema({ _id: String, - allocations: [{ _id: false, repoId: String, bytes: Number, baseBytes: Number, + allocations: [{ _id: false, repoId: String, reservationId: String, bytes: Number, baseBytes: Number, expansions: [{ _id: false, key: String, bytes: Number }], createdAt: Date }], })); // A short lease serializes Stripe checkout creation across API instances. @@ -45,3 +46,6 @@ export const BillingDraft = model("billingDraft", new Schema({ _id: String, owner: { type: String, required: true, index: true }, path: String, values: Schema.Types.Mixed, expiresAt: { type: Date, required: true, expires: 0 }, })); + +// Accepted prices survive removal from the advertised catalog. +export const BillingPrice = model("billingPrice", new Schema({ _id: String, planId: String })); diff --git a/src/core/billing/quotas.ts b/src/core/billing/quotas.ts index 9c811b1..c483c2f 100644 --- a/src/core/billing/quotas.ts +++ b/src/core/billing/quotas.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import ConferenceModel from "../model/conference/conferences.model"; import config from "../../config"; import { BillingGrant, BillingQuota } from "./models"; @@ -35,14 +36,16 @@ export async function reserveRepository(owner: string, repoId: string, bytes: nu for (const repository of existing) { if ((await allowance(String(repository.owner), repository.repoId, repository.conference)).scope === limits.scope) funded.push(repository); } - try { await BillingQuota.create({ _id: limits.scope, allocations: funded.map(r => ({ repoId: r.repoId, bytes: r.size?.storage || 0, createdAt: new Date() })) }); } + try { await BillingQuota.create({ _id: limits.scope, allocations: funded.map(r => ({ repoId: r.repoId, reservationId: randomUUID(), bytes: r.size?.storage || 0, createdAt: new Date() })) }); } catch (error) { if ((error as { code?: number }).code !== 11000) throw error; } } // Only confirmed terminal repositories release capacity. Unknown reservations // remain until reconciled by an operator, so a crash cannot oversell capacity. const ledger = await BillingQuota.findById(limits.scope).lean(); const terminal = await RepositoryModel.find({ repoId: { $in: ledger?.allocations.map(a => a.repoId) || [] }, status: "removed" }).select("repoId").lean(); - if (terminal.length) await BillingQuota.updateOne({ _id: limits.scope }, { $pull: { allocations: { repoId: { $in: terminal.map(r => r.repoId) } } } }); + if (terminal.length) await BillingQuota.updateOne({ _id: limits.scope }, { $pull: { allocations: { $or: terminal.map(r => ({ + repoId: r.repoId, reservationId: ledger?.allocations.find(a => a.repoId === r.repoId)?.reservationId || { $exists: false }, + })) } } }); const others = allocationExpression(repoId); // Never shrink a reservation during refresh. Concurrent generations and // failed builds cannot free bytes still needed by the published generation. @@ -61,7 +64,7 @@ export async function reserveRepository(owner: string, repoId: string, bytes: nu const result = await BillingQuota.updateOne({ _id: limits.scope, $expr: { $and: [ { $lt: [{ $size: others }, limits.repositories] }, { $lte: [{ $add: [{ $sum: { $map: { input: others, as: "a", in: "$$a.bytes" } } }, reservedBytes] }, limits.storage] }, - ] } }, [{ $set: { allocations: { $concatArrays: [others, [{ repoId: { $literal: repoId }, bytes: reservedBytes, baseBytes, expansions, createdAt: new Date() }]] } } }]); + ] } }, [{ $set: { allocations: { $concatArrays: [others, [{ repoId: { $literal: repoId }, reservationId: randomUUID(), bytes: reservedBytes, baseBytes, expansions, createdAt: new Date() }]] } } }]); if (!result.matchedCount) throw billingError("quota_exceeded", 402); } diff --git a/src/core/billing/stripe.ts b/src/core/billing/stripe.ts index 1bee291..ee5df38 100644 --- a/src/core/billing/stripe.ts +++ b/src/core/billing/stripe.ts @@ -1,7 +1,7 @@ import Stripe = require("stripe"); import { randomUUID } from "crypto"; import config from "../../config"; -import { BillingAccount, BillingGrant, BillingLock, BillingRevocation } from "./models"; +import { BillingAccount, BillingGrant, BillingLock, BillingRevocation, BillingPrice } from "./models"; import { billingError, billingOrigin, billingPlans, requireBilling } from "./config"; import ConferenceModel from "../model/conference/conferences.model"; import RepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model"; @@ -68,7 +68,16 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) { if (session.status !== "complete") return; if (session.subscription) { await syncSubscription(objectId(session.subscription)!, stripe); return; } const plan = billingPlans().find(p => p.id === planId && !p.recurring); - if (!plan || session.line_items?.data.length !== 1 || session.line_items.data[0].price?.id !== plan.priceId || session.line_items.data[0].quantity !== 1) throw billingError("billing_invalid_purchase"); + const priceId = session.line_items?.data[0]?.price?.id; + const previousGrant = await BillingGrant.findById(id).lean(); + const historicalPrice = priceId ? await BillingPrice.findById(priceId).lean() : null; + const previousScope = planId === "conference" ? `conference:${target}` : `repo:${target}`; + const acceptedGrant = previousGrant?.owner === owner && previousGrant?.customerId === account.customerId + && previousGrant?.planId === planId && previousGrant?.scope === previousScope + && (!previousGrant.priceId || previousGrant.priceId === priceId); + if (!plan || !priceId || session.line_items?.data.length !== 1 || session.line_items.data[0].quantity !== 1 || + !(priceId === plan.priceId || historicalPrice?.planId === planId || acceptedGrant)) throw billingError("billing_invalid_purchase"); + await BillingPrice.updateOne({ _id: priceId }, { $setOnInsert: { planId } }, { upsert: true }); if (session.payment_status !== "paid" || session.status !== "complete") return; const intent = session.payment_intent as Stripe.PaymentIntent | null; const charge = intent?.latest_charge as Stripe.Charge | null; @@ -82,7 +91,7 @@ export async function fulfillCheckout(id: string, stripe = stripeClient()) { until = conference.endDate; } await BillingGrant.updateOne({ _id: id }, { $setOnInsert: { owner, scope, planId, customerId: account.customerId, - paymentIntentId: objectId(session.payment_intent), status: "paid", until }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true }); + paymentIntentId: objectId(session.payment_intent), priceId, status: "paid", until }, ...(revoked ? { $set: { revoked: true } } : {}) }, { upsert: true }); const reversal = intent && await BillingRevocation.findById(intent.id).lean(); if (reversal?.reviewed) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: false } }); else if (reversal) await BillingGrant.updateOne({ _id: id }, { $set: { revoked: true, revocationReason: reversal.reason } }); @@ -223,6 +232,10 @@ export async function createCheckout(owner: string, planId: string, target = "", account.checkout.params = params; await account.save(); } + if (!plan.recurring) { + const acceptedPrice = (account.checkout.params as Stripe.Checkout.SessionCreateParams).line_items?.[0]?.price; + if (acceptedPrice) await BillingPrice.updateOne({ _id: acceptedPrice }, { $setOnInsert: { planId } }, { upsert: true }); + } const session = await stripe.checkout.sessions.create(account.checkout.params as Stripe.Checkout.SessionCreateParams, { idempotencyKey: account.checkout.key }); account.checkout.id = session.id; await account.save(); diff --git a/src/server/routes/repository-private.ts b/src/server/routes/repository-private.ts index b22d45b..ed921f7 100644 --- a/src/server/routes/repository-private.ts +++ b/src/server/routes/repository-private.ts @@ -207,6 +207,7 @@ router.post( ); validateBillingExpiration({ ...repo.model.options, expirationDate: newExpiration }); const reactivating = repo.status === RepositoryStatus.EXPIRED; + if (reactivating) await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repo.model.conference); const updates: Record = { "options.expirationDate": newExpiration, settingsSavedAt: new Date(), @@ -498,6 +499,7 @@ export function shouldReactivateInactiveRepository( router.post( "/:repoId/", async (req, res) => { + let recoverReactivation: (() => Promise) | undefined; try { const repo = await getRepo(req, res, { nocheck: true, @@ -551,6 +553,22 @@ router.post( const needsAdmission = sourceChanged || reactivating || repoUpdate.conference !== repo.model.conference; if (needsAdmission) { await assertConferenceAdmission(String(repo.model.owner), repo.repoId, repoUpdate.conference); + if (reactivating && config.BILLING_ENABLED && repo.model.billingManaged) { + const previousStatus = repo.status; + const previousStatusDate = repo.model.statusDate; + const claimedAt = new Date(); + const claim = await AnonymizedRepositoryModel.updateOne({ _id: repo.model._id, + status: previousStatus, statusDate: previousStatusDate, + }, { $set: { status: RepositoryStatus.PREPARING, statusDate: claimedAt } }).exec(); + if (!claim.matchedCount) throw appError("invalid_status", 409); + repo.model.status = RepositoryStatus.PREPARING; + repo.model.statusDate = claimedAt; + recoverReactivation = async () => { + await AnonymizedRepositoryModel.updateOne({ _id: repo.model._id, + status: RepositoryStatus.PREPARING, statusDate: claimedAt, + }, { $set: { status: previousStatus, statusDate: previousStatusDate || new Date() } }).exec(); + }; + } if (repo.model.billingManaged && !sourceChanged) await reserveRepository(String(repo.model.owner), repo.repoId, repo.model.size?.storage || 0, repoUpdate.conference); } @@ -673,6 +691,7 @@ router.post( } ).exec(); if (!saved.matchedCount) throw appError("connection_changed", 409); + recoverReactivation = undefined; if (user.id === repo.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "repository", id: String(repo.model._id) }); if (!sourceChanged && !reactivating) { return res.json({ status: repo.status }); @@ -684,6 +703,7 @@ router.post( await enqueueSavedSnapshot(repo.model); res.json({ status: repo.status }); } catch (error) { + if (recoverReactivation) await recoverReactivation(); return handleError(error, res, req); } } diff --git a/test/billing-integration.test.js b/test/billing-integration.test.js index 2094b1b..96d6253 100644 --- a/test/billing-integration.test.js +++ b/test/billing-integration.test.js @@ -4,7 +4,7 @@ const { expect } = require("chai"); require("ts-node/register/transpile-only"); const mongoose = require("mongoose"); const config = require("../src/config").default; -const { BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft } = require("../src/core/billing/models"); +const { BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft } = require("../src/core/billing/models"); const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default; const Conference = require("../src/core/model/conference/conferences.model").default; const { reserveRepository, allowance, assertConferenceAdmission } = require("../src/core/billing/quotas"); @@ -21,12 +21,12 @@ async function rejects(promise, message) { before(async () => { if (!/^billing_test_/.test(new URL(uri).pathname.slice(1))) throw new Error("Use a disposable billing_test_ database"); await mongoose.connect(uri); - await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.init())); + await Promise.all([BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.init())); }); beforeEach(async () => { Object.assign(config, { BILLING_ENABLED: true, FREE_AUTHOR_REPOSITORIES: 3, FREE_AUTHOR_STORAGE: 100, PAID_AUTHOR_STORAGE: 1000, PAID_AUTHOR_REPOSITORIES: 20, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly", STRIPE_AUTHOR_PASS_PRICE: "price_pass", STRIPE_CONFERENCE_PRICE: "price_conf" }); - await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({}))); + await Promise.all([BillingPrice, BillingAccount, BillingGrant, BillingQuota, BillingLock, BillingEvent, BillingRevocation, BillingDraft, Repo, Conference].map(m => m.deleteMany({}))); await BillingAccount.create({ _id: owner, customerId: "cus_owner" }); }); after(async () => { Object.assign(config, original); await mongoose.disconnect(); }); @@ -108,6 +108,61 @@ async function rejects(promise, message) { } else await reserveRepository(owner, "competitor", 1); }); } + for (const fails of [false, true]) { + it(`claims removed restoration before admission and ${fails ? "recovers a failed admission" : "protects it from a competitor"}`, async () => { + const Repository = require("../src/core/Repository").default; + const utils = require("../src/server/routes/route-utils"); + const quotas = require("../src/core/billing/quotas"); + const queue = require("../src/queue"); + const saved = { getRepo: utils.getRepo, getUser: utils.getUser, handleError: utils.handleError, reserve: quotas.reserveRepository, queue: queue.downloadQueue }; + config.FREE_AUTHOR_REPOSITORIES = 1; + const model = await Repo.create({ repoId: "restore", owner, billingManaged: true, status: "removed", statusDate: new Date(0), + source: { type: "GitHubStream", repositoryName: "owner/repo", commit: "abc123", branch: "main" }, size: { storage: 10 } }); + utils.getRepo = async () => new Repository(model); + utils.getUser = async () => ({ isAdmin: true }); + utils.handleError = error => { throw error; }; + let queued = false; + queue.downloadQueue = { add: async () => { queued = true; } }; + quotas.reserveRepository = async (...args) => { + expect((await Repo.findById(model._id)).status).to.equal("preparing"); + if (fails) throw Error("quota_exceeded"); + await saved.reserve(...args); + await rejects(saved.reserve(owner, "competitor", 1), "quota_exceeded"); + }; + try { + const router = require("../src/server/routes/repository-private").default; + const handler = router.stack.find(layer => layer.route?.path === "/:repoId/" && layer.route.methods.post).route.stack[0].handle; + const work = handler({ body: { repoId: "restore", fullName: "owner/repo", source: { commit: "abc123", branch: "main" }, terms: [], + options: { expirationMode: "remove", expirationDate: future() } } }, { json() {} }); + if (fails) await rejects(work, "quota_exceeded"); else await work; + expect(queued).to.equal(!fails); + expect((await Repo.findById(model._id)).status).to.equal(fails ? "removed" : "preparing"); + } finally { + utils.getRepo = saved.getRepo; utils.getUser = saved.getUser; utils.handleError = saved.handleError; + quotas.reserveRepository = saved.reserve; queue.downloadQueue = saved.queue; + } + }); + } + it("does not let stale removal cleanup delete a fresh reservation", async () => { + config.FREE_AUTHOR_REPOSITORIES = 1; + await Repo.create({ repoId: "restoring", owner, status: "ready", size: { storage: 10 } }); + await reserveRepository(owner, "restoring", 10); + await Repo.updateOne({ repoId: "restoring" }, { $set: { status: "removed" } }); + const update = BillingQuota.updateOne; + let interleaved = false; + BillingQuota.updateOne = async function (...args) { + if (args[1].$pull && !interleaved) { + interleaved = true; + await Repo.updateOne({ repoId: "restoring" }, { $set: { status: "preparing" } }); + await reserveRepository(owner, "restoring", 20); + } + return update.apply(this, args); + }; + try { await rejects(reserveRepository(owner, "competitor", 1), "quota_exceeded"); } + finally { BillingQuota.updateOne = update; } + expect(interleaved).to.equal(true); + expect((await BillingQuota.findById(`user:${owner}`)).allocations[0].bytes).to.equal(20); + }); 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"); @@ -199,6 +254,29 @@ async function rejects(promise, message) { await fulfillCheckout("cs_pass", stripe); expect((await BillingGrant.findById("cs_pass")).revoked).to.equal(true); }); + it("reconciles retired one-time prices and continues refund recovery", async () => { + const { reconcileBillingAccount } = require("../src/core/billing/reconciliation"); + const iterable = values => ({ async *[Symbol.asyncIterator]() { yield* values; } }); + const value = session(); + const stripe = { checkout: { sessions: { retrieve: async () => value, list: () => iterable([value]) } }, + subscriptions: { list: () => iterable([]) }, charges: { list: () => iterable([{ payment_intent: "pi_pass", refunded: true }]) }, + invoicePayments: { list: async () => ({ data: [] }) } }; + await fulfillCheckout(value.id, stripe); + config.STRIPE_AUTHOR_PASS_PRICE = ""; + await BillingGrant.deleteMany({}); + await reconcileBillingAccount(owner, stripe); + expect((await BillingGrant.findById(value.id)).revoked).to.equal(true); + expect((await BillingAccount.findById(owner)).reconciledAt).to.be.instanceOf(Date); + value.line_items.data[0].price.id = "price_unrecognized"; + await rejects(fulfillCheckout(value.id, stripe), "billing_invalid_purchase"); + }); + it("preserves previously accepted grants without historical price fields", async () => { + const value = session(); + await BillingGrant.create({ _id: value.id, owner, customerId: "cus_owner", scope: "repo:repo", planId: "author_pass", status: "paid" }); + config.STRIPE_AUTHOR_PASS_PRICE = ""; + await fulfillCheckout(value.id, { checkout: { sessions: { retrieve: async () => value } } }); + expect((await BillingPrice.findById("price_pass")).planId).to.equal("author_pass"); + }); it("rejects a checkout for a different Stripe customer", async () => { const value = session(); value.customer = "cus_other"; await rejects(fulfillCheckout("cs_pass", { checkout: { sessions: { retrieve: async () => value } } }), "billing_customer_mismatch"); diff --git a/test/billing-reliability.test.js b/test/billing-reliability.test.js index 3862be6..bd1c8ad 100644 --- a/test/billing-reliability.test.js +++ b/test/billing-reliability.test.js @@ -3,7 +3,7 @@ const { expect } = require("chai"); require("ts-node/register/transpile-only"); const mongoose = require("mongoose"); const config = require("../src/config").default; -const { BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, BillingDraft } = require("../src/core/billing/models"); +const { BillingPrice, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, BillingDraft } = require("../src/core/billing/models"); const { receiveBillingEvent, processBillingInbox, billingHealth } = require("../src/core/billing/events"); const { reconcileBillingAccount } = require("../src/core/billing/reconciliation"); const { createCheckout, processBillingEvent } = require("../src/core/billing/stripe"); @@ -12,7 +12,7 @@ const Repo = require("../src/core/model/anonymizedRepositories/anonymizedReposit const uri = process.env.BILLING_TEST_MONGODB_URI; const owner = "507f1f77bcf86cd799439011"; const { saveBillingDraft, getBillingDraft } = require("../src/core/billing/drafts"); -const models = [BillingDraft, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, Repo]; +const models = [BillingDraft, BillingPrice, BillingAccount, BillingGrant, BillingEvent, BillingRevocation, BillingLock, Repo]; const iterable = values => ({ async *[Symbol.asyncIterator]() { yield* values; } }); const event = id => ({ id, livemode: false, type: "customer.created", data: { object: { id: "cus_test", email: "private@example.org" } } }); (uri ? describe : describe.skip)("billing failure recovery", function () { @@ -158,6 +158,22 @@ const event = id => ({ id, livemode: false, type: "customer.created", data: { ob retrieve: async () => ({ status: "open", url: "https://checkout.stripe.com/recovered" }), } } }; } + it("records an accepted one-time price before an uncertain checkout response", async () => { + config.STRIPE_AUTHOR_PASS_PRICE = "price_retiring"; + await Repo.create({ repoId: "pass-target", owner, status: "ready" }); + const stripe = checkoutStripe(); + stripe.prices.retrieve = async () => ({ active: true, currency: "eur", unit_amount: 1500 }); + try { await createCheckout(owner, "author_pass", "pass-target", stripe); throw Error("expected network failure"); } + catch (error) { expect(error.message).to.equal("connection lost"); } + expect((await BillingPrice.findById("price_retiring")).planId).to.equal("author_pass"); + config.STRIPE_AUTHOR_PASS_PRICE = ""; + const { fulfillCheckout } = require("../src/core/billing/stripe"); + stripe.checkout.sessions.retrieve = async () => ({ id: "cs_retired", status: "complete", payment_status: "paid", customer: "cus_owner", + created: Math.floor(Date.now() / 1000), metadata: { application: "anonymous-github", owner, planId: "author_pass", target: "pass-target" }, + line_items: { data: [{ price: { id: "price_retiring" }, quantity: 1 }] }, payment_intent: null }); + await fulfillCheckout("cs_retired", stripe); + expect((await BillingGrant.findById("cs_retired")).status).to.equal("paid"); + }); it("keeps checkout drafts private, bounded and expired even before the TTL sweep", async () => { const draft = await saveBillingDraft(owner, "/anonymize", { terms: "Private Author", options: { expirationMode: "never" }, customerId: "untrusted" }); expect((await getBillingDraft(owner, draft.id)).values).not.to.have.property("customerId"); diff --git a/test/repository-activity.test.js b/test/repository-activity.test.js index 3a95e55..44c2be9 100644 --- a/test/repository-activity.test.js +++ b/test/repository-activity.test.js @@ -141,6 +141,19 @@ describe("repository activity dates", () => { expect(writes).to.equal(2); expect(repo.model.options.expirationDate).to.deep.equal(savedDate); }); + it("does not extend an expired submission in a conference that no longer admits it", async () => { + const repo = repository("expired"); auth(repo); + repo.model.conference = "ended"; + const quotas = require("../src/core/billing/quotas"); + stub(quotas, "assertConferenceAdmission", async (_owner, _id, conference) => { + expect(conference).to.equal("ended"); throw Error("conf_not_activated"); + }); + stub(Model, "updateOne", () => { throw Error("must not persist extension"); }); + let error; + try { await handler("repository-private", "/:repoId/extend", "post")({}, {}); } catch (caught) { error = caught; } + expect(error?.message).to.equal("conf_not_activated"); + expect(repo.status).to.equal("expired"); + }); it("dates a dashboard expiration extension", async () => { const repo = repository(); auth(repo); let saved; stub(Model, "updateOne", (_filter, update) => ({ exec: async () => { saved = update.$set; } }));