feat: add paid author and conference hosting with Stripe

This commit is contained in:
Thomas Durieux committed 2026-11-02 20:49:03 +00:00
1 parent b09ef0e5b0
commit 410863d253
54 files changed
+3263 -369

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
const { URL } = require("node:url");
const { setTimeout } = require("node:timers");
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 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");
const { fulfillCheckout, syncSubscription, createCheckout, processBillingEvent } = require("../src/core/billing/stripe");
const uri = process.env.BILLING_TEST_MONGODB_URI;
const owner = "507f1f77bcf86cd799439011";
const future = () => new Date(Date.now() + 86400000);
async function rejects(promise, message) {
try { await promise; throw new Error("Expected rejection"); } catch (error) { expect(error.message).to.equal(message); }
}
(uri ? describe : describe.skip)("billing with MongoDB", function () {
this.timeout(15000);
const original = { ...config };
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()));
});
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 BillingAccount.create({ _id: owner, customerId: "cus_owner" });
});
after(async () => { Object.assign(config, original); await mongoose.disconnect(); });
it("atomically admits only three concurrent repositories", async () => {
const results = await Promise.allSettled(Array.from({ length: 12 }, (_, i) => reserveRepository(owner, `repo-${i}`, 10)));
expect(results.filter(r => r.status === "fulfilled")).to.have.length(3);
expect((await BillingQuota.findById(`user:${owner}`)).allocations).to.have.length(3);
});
it("atomically caps storage and never shrinks a concurrent reservation", async () => {
const results = await Promise.allSettled([reserveRepository(owner, "a", 60), reserveRepository(owner, "b", 60)]);
expect(results.filter(r => r.status === "fulfilled")).to.have.length(1);
const ledger = await BillingQuota.findById(`user:${owner}`);
const id = ledger.allocations[0].repoId;
await Promise.all([reserveRepository(owner, id, 90), reserveRepository(owner, id, 1)]);
expect((await BillingQuota.findById(`user:${owner}`)).allocations[0].bytes).to.equal(90);
});
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");
await Repo.updateOne({ repoId: "one" }, { $set: { status: "removed" } });
await reserveRepository(owner, "four", 1);
expect((await BillingQuota.findById(`user:${owner}`)).allocations.map(a => a.repoId)).not.to.include("one");
});
it("charges shared repositories only to their owner, regardless of coauthor subscriptions", async () => {
const collaborator = "507f1f77bcf86cd799439012";
await Repo.create(["one", "two", "three"].map(repoId => ({
repoId, owner, status: "ready", size: { storage: 10 },
coauthors: [{ username: "collaborator", githubId: "42" }],
})));
// A collaborator can create their own repository even when the owner is full.
await reserveRepository(collaborator, "own-work", 10);
expect((await BillingQuota.findById(`user:${collaborator}`)).allocations.map(a => a.repoId)).to.deep.equal(["own-work"]);
await BillingGrant.create({ _id: "coauthor-sub", owner: collaborator,
scope: `user:${collaborator}`, status: "active", until: future(), planId: "author_monthly" });
expect((await allowance(owner, "one")).plan).to.equal("free");
await rejects(reserveRepository(owner, "four", 1), "quota_exceeded");
// Editing shared work at the owner's count cap is allowed if its bytes fit.
await reserveRepository(owner, "one", 20);
await rejects(reserveRepository(owner, "one", 90), "quota_exceeded");
});
it("uses a conference allowance before an author's subscription", async () => {
await BillingGrant.create([{ _id: "sub", owner, scope: `user:${owner}`, status: "active", until: future(), planId: "author_monthly" },
{ _id: "conf", owner, scope: "conference:CONF", status: "paid", until: future(), planId: "conference" }]);
expect((await allowance(owner, "repo", "CONF")).scope).to.equal("conference:CONF");
await BillingGrant.updateOne({ _id: "conf" }, { $set: { revoked: true } });
expect((await allowance(owner, "repo", "CONF")).scope).to.equal(`user:${owner}`);
});
it("requires the owner's recurring subscription for never expiration", async () => {
const { neverExpirationDeadline, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
await rejects(neverExpirationDeadline(owner), "subscription_required_for_never_expiration");
await BillingGrant.create({ _id: "pass-only", owner, scope: "repo:repo", status: "paid", until: future(), planId: "author_pass" });
await rejects(neverExpirationDeadline(owner), "subscription_required_for_never_expiration");
const until = future();
await BillingGrant.create({ _id: "author", owner, scope: `user:${owner}`, status: "active", until, planId: "author_annual" });
expect((await neverExpirationDeadline(owner)).getTime()).to.equal(until.getTime() + NEVER_EXPIRATION_GRACE_MS);
});
it("renews never expiration, honors 90 days of grace and leaves legacy links alone", async () => {
const { reconcileNeverExpiration, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
const until = future();
const oldDeadline = new Date(Date.now() + 1000);
await Repo.create([{ repoId: "paid-never", owner, status: "ready", options: { expirationMode: "never" }, billingNeverExpiresUntil: oldDeadline },
{ repoId: "legacy-never", owner, status: "ready", options: { expirationMode: "never" } }]);
await BillingGrant.create({ _id: "author", owner, scope: `user:${owner}`, status: "active", until, planId: "author_monthly" });
await reconcileNeverExpiration();
const deadline = new Date(until.getTime() + NEVER_EXPIRATION_GRACE_MS);
expect((await Repo.findOne({ repoId: "paid-never" })).billingNeverExpiresUntil.getTime()).to.equal(deadline.getTime());
await reconcileNeverExpiration(new Date(deadline.getTime() - 1));
expect((await Repo.findOne({ repoId: "paid-never" })).options.expirationMode).to.equal("never");
await reconcileNeverExpiration(deadline, async () => {});
const expired = await Repo.findOne({ repoId: "paid-never" });
expect(expired.options.expirationMode).to.equal("remove");
expect(expired.options.expirationDate.getTime()).to.equal(deadline.getTime());
expect((await Repo.findOne({ repoId: "legacy-never" })).options.expirationMode).to.equal("never");
});
function session(status = "paid") {
return { id: "cs_pass", metadata: { application: "anonymous-github", owner, planId: "author_pass", target: "repo" },
customer: "cus_owner", status: "complete", payment_status: status, created: Math.floor(Date.now() / 1000),
line_items: { data: [{ price: { id: "price_pass" }, quantity: 1 }] }, payment_intent: { id: "pi_pass", latest_charge: { amount_refunded: 0, disputed: false } } };
}
it("ignores an unfinished recurring Checkout before Stripe assigns a subscription", async () => {
const stripe = { checkout: { sessions: { retrieve: async () => ({
id: "cs_open", metadata: { application: "anonymous-github", owner, planId: "author_monthly" },
customer: "cus_owner", status: "open", payment_status: "unpaid", subscription: null,
}) } } };
await fulfillCheckout("cs_open", stripe);
expect(await BillingGrant.countDocuments()).to.equal(0);
});
it("ignores unpaid checkout and fulfills duplicate deliveries exactly once", async () => {
let value = session("unpaid");
const stripe = { checkout: { sessions: { retrieve: async () => value } } };
await fulfillCheckout("cs_pass", stripe);
expect(await BillingGrant.countDocuments()).to.equal(0);
value = session();
await Promise.all([fulfillCheckout("cs_pass", stripe), fulfillCheckout("cs_pass", stripe)]);
const grant = await BillingGrant.findById("cs_pass");
expect(await BillingGrant.countDocuments()).to.equal(1);
expect(grant.scope).to.equal("repo:repo");
expect(grant.until.getTime()).to.equal(value.created * 1000 + 180 * 86400000);
});
it("does not reactivate a refunded pass on a checkout replay", async () => {
const value = session();
const stripe = { checkout: { sessions: { retrieve: async () => value } } };
await fulfillCheckout("cs_pass", stripe);
await processBillingEvent({ type: "charge.refunded", data: { object: { payment_intent: "pi_pass" } } }, { ...stripe, invoicePayments: { list: async () => ({ data: [] }) } });
await fulfillCheckout("cs_pass", stripe);
expect((await BillingGrant.findById("cs_pass")).revoked).to.equal(true);
});
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");
});
function subscription(overrides = {}) {
return { id: "sub_owner", metadata: { application: "anonymous-github", owner }, customer: "cus_owner", status: "active",
latest_invoice: { status: "paid" }, items: { data: [{ price: { id: "price_monthly" }, current_period_end: Math.floor(future().getTime() / 1000) }] }, cancel_at_period_end: false, ...overrides };
}
it("recognizes portal cancellations scheduled with cancel_at", async () => {
await syncSubscription("sub_owner", { subscriptions: { retrieve: async () => subscription({
cancel_at_period_end: false, cancel_at: Math.floor(future().getTime() / 1000),
}) } });
expect((await BillingGrant.findById("sub_owner")).cancelAtPeriodEnd).to.equal(true);
expect((await allowance(owner)).plan).to.equal("author_monthly");
});
it("honors paid subscription periods, cancellation and renewal failure", async () => {
let value = subscription();
const stripe = { subscriptions: { retrieve: async () => value } };
await syncSubscription("sub_owner", stripe);
expect((await allowance(owner)).plan).to.equal("author_monthly");
const paidThrough = (await BillingGrant.findById("sub_owner")).until;
value = subscription({ status: "past_due", latest_invoice: { status: "open" } });
value.items.data[0].current_period_end += 86400 * 30;
await syncSubscription("sub_owner", stripe);
expect((await BillingGrant.findById("sub_owner")).until).to.deep.equal(paidThrough);
value = subscription({ cancel_at_period_end: true });
await syncSubscription("sub_owner", stripe);
expect((await allowance(owner)).plan).to.equal("author_monthly");
value = subscription({ status: "canceled" });
await syncSubscription("sub_owner", stripe);
expect((await allowance(owner)).plan).to.equal("free");
});
it("protects a newer subscription update from an older in-flight fetch", async () => {
await BillingGrant.create({ _id: "sub_owner", owner, scope: `user:${owner}`, planId: "author_monthly", status: "active", until: future() });
let release; const gate = new Promise(resolve => { release = resolve; }); let started;
const firstRead = new Promise(resolve => { started = resolve; }); let calls = 0;
const stripe = { subscriptions: { retrieve: async () => {
if (++calls === 1) { started(); await gate; return subscription(); }
return subscription({ status: "canceled" });
} } };
const old = syncSubscription("sub_owner", stripe);
await firstRead;
await syncSubscription("sub_owner", stripe);
release(); await old;
expect((await BillingGrant.findById("sub_owner")).status).to.equal("canceled");
});
it("keeps conference admission closed until verified payment", async () => {
await Conference.create({ conferenceID: "CONF", owners: [owner], status: "pending_payment", startDate: new Date(Date.now() - 1000), endDate: future(), plan: { planID: "conference" } });
await rejects(assertConferenceAdmission(owner, "repo", "CONF"), "conf_not_activated");
const value = session(); value.id = "cs_conf"; value.metadata.planId = "conference"; value.metadata.target = "CONF"; value.line_items.data[0].price.id = "price_conf";
await fulfillCheckout("cs_conf", { checkout: { sessions: { retrieve: async () => value } } });
expect((await Conference.findOne({ conferenceID: "CONF" })).status).to.equal("ready");
await assertConferenceAdmission(owner, "repo", "CONF");
await BillingGrant.updateOne({ _id: "cs_conf" }, { $set: { revoked: true } });
await rejects(assertConferenceAdmission(owner, "repo", "CONF"), "conference_payment_required");
});
it("prevents two simultaneous checkout sessions and reuses an open session", async () => {
let creates = 0;
const stripe = { prices: { retrieve: async () => ({ active: true, currency: "eur", unit_amount: 500, recurring: { interval: "month", interval_count: 1 } }) }, subscriptions: { list: async () => ({ data: [] }) }, checkout: { sessions: {
create: async () => { creates++; await new Promise(r => setTimeout(r, 30)); return { id: "cs_new", url: "https://checkout.stripe.com/test" }; },
retrieve: async () => ({ id: "cs_new", status: "open", url: "https://checkout.stripe.com/test" }),
} } };
const results = await Promise.allSettled([createCheckout(owner, "author_monthly", "", stripe), createCheckout(owner, "author_monthly", "", stripe)]);
expect(results.filter(r => r.status === "fulfilled")).to.have.length(1);
await createCheckout(owner, "author_monthly", "", stripe);
expect(creates).to.equal(1);
});
it("revokes only the refunded subscription, including refunds arriving before subscription sync", async () => {
await BillingGrant.create({ _id: "other-sub", owner, customerId: "cus_owner", subscriptionId: "other-sub", status: "active", until: future() });
const stripe = { invoicePayments: { list: async () => ({ data: [{ invoice: "in_refunded" }] }) },
invoices: { retrieve: async () => ({ parent: { subscription_details: { subscription: "sub_refunded" } } }) } };
await processBillingEvent({ type: "charge.refunded", data: { object: { customer: "cus_owner", payment_intent: "pi_refunded" } } }, stripe);
expect((await BillingGrant.findById("sub_refunded")).revoked).to.equal(true);
expect((await BillingGrant.findById("other-sub")).revoked).to.equal(false);
await syncSubscription("sub_refunded", { subscriptions: { retrieve: async () => subscription({ id: "sub_refunded" }) } });
expect((await BillingGrant.findById("sub_refunded")).revoked).to.equal(true);
});
it("keeps reviewer visits from refreshing snapshots after paid access expires", async () => {
const Repository = require("../src/core/Repository").default;
const model = new Repo({ repoId: "frozen", owner, billingManaged: true, status: "ready", lastView: new Date(0), options: { update: true, expirationMode: "never" }, source: { type: "GitHubStream", repositoryName: "owner/repo" } });
const repo = new Repository(model);
repo.getToken = () => { throw new Error("Unexpected upstream refresh"); };
await BillingGrant.create({ _id: "expired-sub", owner, scope: `user:${owner}`, status: "active", until: new Date(0), planId: "author_monthly" });
await repo.updateIfNeeded();
expect(repo.status).to.equal("ready");
});
it("authenticates billing, checks CSRF, and verifies raw webhook signatures", async () => {
const express = require("express");
const sessionMiddleware = require("express-session");
const Stripe = require("stripe");
const Users = require("../src/core/model/users/users.model").default;
const routes = require("../src/server/routes/billing");
await Users.updateOne({ _id: owner }, { $set: { username: "billing-test-author", status: "active" } }, { upsert: true });
Object.assign(config, { STRIPE_SECRET_KEY: "sk_test_example", STRIPE_WEBHOOK_SECRET: "whsec_test_example", STRIPE_AUTHOR_MONTHLY_PRICE: "", STRIPE_AUTHOR_ANNUAL_PRICE: "", STRIPE_AUTHOR_PASS_PRICE: "", STRIPE_CONFERENCE_PRICE: "" });
const app = express();
app.use("/webhook", routes.billingWebhook);
app.use(express.json());
app.use(sessionMiddleware({ secret: "billing-test-session-secret", resave: false, saveUninitialized: false }));
app.use((req, _res, next) => { req.isAuthenticated = () => req.headers["x-test-auth"] === "yes"; req.user = { user: { _id: owner } }; next(); });
app.use("/billing", routes.default);
const server = await new Promise(resolve => { const listening = app.listen(0, "127.0.0.1", () => resolve(listening)); });
const base = `http://127.0.0.1:${server.address().port}`;
try {
expect((await globalThis.fetch(`${base}/billing`)).status).to.equal(401);
const get = await globalThis.fetch(`${base}/billing`, { headers: { "x-test-auth": "yes" } });
const data = await get.json();
expect(data.csrf).to.be.a("string");
expect(JSON.stringify(data)).not.to.include("cus_owner");
const headers = { "x-test-auth": "yes", cookie: get.headers.get("set-cookie").split(";")[0], "content-type": "application/json" };
expect((await globalThis.fetch(`${base}/billing/checkout`, { method: "POST", headers, body: JSON.stringify({ planId: "arbitrary" }) })).status).to.equal(403);
expect((await globalThis.fetch(`${base}/billing/draft`, { method: "POST", headers, body: JSON.stringify({ path: "/anonymize", values: { terms: "Private Author" } }) })).status).to.equal(403);
headers["x-csrf-token"] = data.csrf;
const saved = await globalThis.fetch(`${base}/billing/draft`, { method: "POST", headers, body: JSON.stringify({ path: "/anonymize", values: { terms: "Private Author" } }) });
expect(saved.status).to.equal(200);
const draft = await saved.json();
expect((await globalThis.fetch(`${base}/billing/draft/${draft.id}`)).status).to.equal(401);
const restored = await globalThis.fetch(`${base}/billing/draft/${draft.id}`, { headers });
expect(restored.headers.get("cache-control")).to.equal("no-store");
expect((await restored.json()).values.terms).to.equal("Private Author");
await BillingDraft.updateOne({ _id: draft.id }, { owner: "another-owner" });
expect((await globalThis.fetch(`${base}/billing/draft/${draft.id}`, { headers })).status).to.equal(404);
const rejected = await globalThis.fetch(`${base}/billing/checkout`, { method: "POST", headers, body: JSON.stringify({ planId: "arbitrary" }) });
expect((await rejected.json()).error).to.equal("billing_plan_unavailable");
const payload = JSON.stringify({ id: "evt_test", type: "customer.created", data: { object: { id: "cus_unused" } } });
const stripe = new Stripe("sk_test_example");
const signature = stripe.webhooks.generateTestHeaderString({ payload, secret: config.STRIPE_WEBHOOK_SECRET });
expect((await globalThis.fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": signature }, body: payload })).status).to.equal(200);
expect((await globalThis.fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": signature }, body: payload + " " })).status).to.equal(400);
expect((await BillingEvent.findById("test:platform:evt_test")).state).to.equal("pending");
const originalUpdate = BillingEvent.updateOne;
BillingEvent.updateOne = async () => { throw new Error("simulated database outage"); };
try {
expect((await globalThis.fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": signature }, body: payload })).status).to.equal(500);
} finally { BillingEvent.updateOne = originalUpdate; }
} finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await Users.deleteOne({ _id: owner }); }
});
});
+233
View File
@@ -0,0 +1,233 @@
const { URL } = require("url");
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 { receiveBillingEvent, processBillingInbox, billingHealth } = require("../src/core/billing/events");
const { reconcileBillingAccount } = require("../src/core/billing/reconciliation");
const { createCheckout, processBillingEvent } = require("../src/core/billing/stripe");
const { reconcileNeverExpiration, NEVER_EXPIRATION_GRACE_MS } = require("../src/core/billing/expiration");
const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
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 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 () {
this.timeout(20000);
const original = { ...config };
before(async () => {
if (!/^billing_test_/.test(new URL(uri).pathname.slice(1))) throw new Error("Disposable database required");
await mongoose.connect(uri);
await Promise.all(models.map(m => m.init()));
});
beforeEach(async () => {
await Promise.all(models.map(m => m.deleteMany({})));
Object.assign(config, { BILLING_ENABLED: true, BILLING_CHECKOUT_ENABLED: true, STRIPE_AUTHOR_MONTHLY_PRICE: "price_monthly" });
await BillingAccount.create({ _id: owner, customerId: "cus_owner" });
});
after(async () => { Object.assign(config, original); await mongoose.disconnect(); });
it("deduplicates concurrent receipt without retaining customer data", async () => {
await Promise.all(Array.from({ length: 8 }, () => receiveBillingEvent(event("evt_one"))));
expect(await BillingEvent.countDocuments()).to.equal(1);
expect(JSON.stringify(await BillingEvent.findOne())).not.to.include("private@example.org");
let calls = 0;
await Promise.all([processBillingInbox(1, async () => { calls++; }), processBillingInbox(1, async () => { calls++; })]);
expect(calls).to.equal(1);
expect((await BillingEvent.findOne()).state).to.equal("complete");
});
it("recovers a crash after side effects but before acknowledgement", async () => {
await receiveBillingEvent(event("evt_crash"));
await BillingGrant.create({ _id: "already-applied", status: "paid" });
await BillingEvent.updateOne({}, { $set: { state: "processing", token: "dead-worker", leaseUntil: new Date(0) } });
await processBillingInbox(1, async () => {
await BillingGrant.updateOne({ _id: "already-applied" }, { $setOnInsert: { status: "paid" } }, { upsert: true });
});
expect(await BillingGrant.countDocuments()).to.equal(1);
expect((await BillingEvent.findOne()).state).to.equal("complete");
});
it("backs off failures, redacts errors and exposes exhausted retries", async () => {
await receiveBillingEvent(event("evt_fail"));
await processBillingInbox(1, async () => { throw new Error("secret payload"); });
let record = await BillingEvent.findOne();
expect(record.state).to.equal("pending");
expect(record.nextAttemptAt.getTime()).to.be.greaterThan(record.receivedAt.getTime());
expect(record.lastError).not.to.include("secret");
await BillingEvent.updateOne({}, { $set: { attempts: 11, nextAttemptAt: new Date(0) } });
await processBillingInbox(1, async () => { throw new Error("secret"); });
record = await BillingEvent.findOne();
expect(record.state).to.equal("failed");
expect((await billingHealth()).failed).to.equal(1);
});
it("recovers missing grants from provider history and applies missed refunds", async () => {
const sub = { id: "sub_lost", metadata: { application: "anonymous-github", owner }, customer: "cus_owner", status: "active",
latest_invoice: { status: "paid" }, items: { data: [{ price: { id: "price_monthly" }, current_period_end: Math.floor(Date.now() / 1000) + 86400 }] } };
const stripe = { subscriptions: { list: () => iterable([sub]), retrieve: async () => sub },
checkout: { sessions: { list: () => iterable([]) } },
charges: { list: () => iterable([{ id: "ch_refunded", refunded: true, payment_intent: "pi_refunded" }]) },
invoicePayments: { list: async () => ({ data: [{ invoice: "in_refunded" }] }) },
invoices: { retrieve: async () => ({ parent: { subscription_details: { subscription: "sub_lost" } } }) } };
await reconcileBillingAccount(owner, stripe);
expect((await BillingGrant.findById("sub_lost")).revoked).to.equal(true);
expect((await BillingAccount.findById(owner)).reconciledAt).to.be.instanceOf(Date);
// A restored database with no grants can reconstruct the same final state.
await BillingGrant.deleteMany({});
await reconcileBillingAccount(owner, stripe);
expect((await BillingGrant.findById("sub_lost")).revoked).to.equal(true);
});
it("recovers paid time when the latest invoice failed after a missed renewal", async () => {
const { syncSubscription } = require("../src/core/billing/stripe");
const end = Math.floor(Date.now() / 1000);
await syncSubscription("sub_history", {
subscriptions: { retrieve: async () => ({ id: "sub_history", customer: "cus_owner", metadata: { application: "anonymous-github", owner },
status: "past_due", latest_invoice: { status: "open" }, items: { data: [{ id: "si_owned", price: { id: "price_monthly" }, current_period_end: end + 86400 }] } }) },
invoices: { list: () => iterable([{ id: "in_paid" }]), listLineItems: () => iterable([
{ amount: 500, parent: { subscription_item_details: { subscription_item: "si_owned" } }, period: { end } },
{ amount: 500, parent: { subscription_item_details: { subscription_item: "si_unrelated" } }, period: { end: end + 999999 } },
]) },
});
expect((await BillingGrant.findById("sub_history")).until.getTime()).to.equal(end * 1000);
});
it("preserves a refund received before the first grant exists", async () => {
const { fulfillCheckout } = require("../src/core/billing/stripe");
config.STRIPE_AUTHOR_PASS_PRICE = "price_pass";
const stripe = { invoicePayments: { list: async () => ({ data: [] }) }, checkout: { sessions: { retrieve: async () => ({
id: "cs_late", status: "complete", payment_status: "paid", customer: "cus_owner", created: Math.floor(Date.now() / 1000),
metadata: { application: "anonymous-github", owner, planId: "author_pass", target: "repo" },
line_items: { data: [{ price: { id: "price_pass" }, quantity: 1 }] },
payment_intent: { id: "pi_early", latest_charge: { refunded: false, disputed: false } },
}) } } };
await processBillingEvent({ type: "charge.refunded", data: { object: { payment_intent: "pi_early", refunded: true } } }, stripe);
await fulfillCheckout("cs_late", stripe);
expect((await BillingGrant.findById("cs_late")).revoked).to.equal(true);
});
it("starts a fresh attempt only after the old immutable request cannot execute", async () => {
await BillingAccount.updateOne({ _id: owner }, { $set: { checkout: {
key: "old", planId: "author_monthly", target: "", createdAt: new Date(0), params: { expires_at: 1000 },
} } });
const stripe = checkoutStripe();
stripe.checkout.sessions.create = async (_params, options) => {
expect(options.idempotencyKey).not.to.equal("old");
return { id: "cs_fresh", url: "https://checkout.stripe.com/fresh" };
};
expect(await createCheckout(owner, "author_monthly", "", stripe)).to.equal("https://checkout.stripe.com/fresh");
});
it("keeps partial refunds active and revokes a full refund", async () => {
await BillingGrant.create({ _id: "pass", paymentIntentId: "pi_pass", status: "paid" });
const stripe = { invoicePayments: { list: async () => ({ data: [] }) } };
const refund = refunded => ({ type: "charge.refunded", data: { object: { payment_intent: "pi_pass", refunded } } });
await processBillingEvent(refund(false), stripe);
expect((await BillingGrant.findById("pass")).revoked).to.equal(false);
await processBillingEvent(refund(true), stripe);
expect((await BillingGrant.findById("pass")).revocationReason).to.equal("full_refund");
});
it("requires review for a new dispute but ignores an older refund replay after review", async () => {
await BillingGrant.create({ _id: "reviewed-pass", paymentIntentId: "pi_reviewed", status: "paid" });
const stripe = { invoicePayments: { list: async () => ({ data: [] }) }, charges: { retrieve: async () => ({ payment_intent: "pi_reviewed", disputed: true }) } };
const refund = { type: "charge.refunded", data: { object: { payment_intent: "pi_reviewed", refunded: true } } };
await processBillingEvent(refund, stripe);
await BillingRevocation.updateOne({ _id: "pi_reviewed" }, { $set: { reviewed: true } });
await BillingGrant.updateOne({ _id: "reviewed-pass" }, { $set: { revoked: false } });
await processBillingEvent({ type: "charge.dispute.created", data: { object: { charge: "ch_reviewed" } } }, stripe);
expect((await BillingGrant.findById("reviewed-pass")).revoked).to.equal(true);
await BillingRevocation.updateOne({ _id: "pi_reviewed" }, { $set: { reviewed: true } });
await BillingGrant.updateOne({ _id: "reviewed-pass" }, { $set: { revoked: false } });
await processBillingEvent(refund, stripe);
expect((await BillingGrant.findById("reviewed-pass")).revoked).to.equal(false);
});
it("defers expiration during outage and discovers a renewal before removing content", async () => {
const deadline = new Date(Date.now() - 1000);
await Repo.create({ repoId: "protected", owner, status: "ready", options: { expirationMode: "never" }, billingNeverExpiresUntil: deadline });
await reconcileNeverExpiration(new Date(), async () => { throw new Error("Stripe unavailable"); });
expect((await Repo.findOne()).options.expirationMode).to.equal("never");
const until = new Date(Date.now() + 86400_000);
await reconcileNeverExpiration(new Date(), async () => {
await BillingGrant.create({ _id: "renewal", owner, scope: `user:${owner}`, planId: "author_monthly", until, status: "active" });
});
const repo = await Repo.findOne();
expect(repo.options.expirationMode).to.equal("never");
expect(repo.billingNeverExpiresUntil.getTime()).to.equal(until.getTime() + NEVER_EXPIRATION_GRACE_MS);
});
function checkoutStripe() {
return { prices: { retrieve: async () => ({ active: true, currency: "eur", unit_amount: 500, recurring: { interval: "month", interval_count: 1 } }) },
subscriptions: { list: async () => ({ data: [] }) }, checkout: { sessions: {
create: async () => { throw new Error("connection lost"); },
list: () => iterable([]),
retrieve: async () => ({ status: "open", url: "https://checkout.stripe.com/recovered" }),
} } };
}
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");
for (const other of ["someone-else", owner]) {
if (other === owner) await BillingDraft.updateOne({ _id: draft.id }, { expiresAt: new Date(0) });
try { await getBillingDraft(other, draft.id); throw new Error("expected rejection"); }
catch (error) { expect(error.message).to.equal("billing_draft_not_found"); }
}
for (const path of ["//evil.example", "/anonymize?next=https://evil.example", "/anonymize/../profile"]) {
try { await saveBillingDraft(owner, path, {}); throw new Error("expected rejection"); }
catch (error) { expect(error.message).to.equal("billing_invalid_draft"); }
}
try { await saveBillingDraft(owner, "/anonymize", { terms: "x".repeat(65537) }); throw new Error("expected rejection"); }
catch (error) { expect(error.message).to.equal("billing_invalid_draft"); }
});
it("returns checkout to the saved task and refuses reuse for a different task", async () => {
const stripe = checkoutStripe();
const draft = await saveBillingDraft(owner, "/anonymize", { terms: "Author" });
const destination = `/anonymize?billingDraft=${draft.id}`;
let parameters;
stripe.checkout.sessions.create = async params => { parameters = params; return { id: "cs_task", url: "https://checkout.stripe.com/task" }; };
await createCheckout(owner, "author_monthly", "", stripe, destination);
expect(parameters.success_url).to.include(destination + "&billing=success");
expect(parameters.cancel_url).to.include(destination + "&billing=cancelled");
await createCheckout(owner, "author_monthly", "", stripe, destination);
try { await createCheckout(owner, "author_monthly", "", stripe); throw new Error("expected rejection"); }
catch (error) { expect(error.message).to.equal("billing_checkout_in_progress"); }
try { await createCheckout(owner, "author_monthly", "", stripe, "https://evil.example"); throw new Error("expected rejection"); }
catch (error) { expect(error.message).to.equal("billing_invalid_draft"); }
});
it("retries a lost response with identical parameters and the same key", async () => {
const stripe = checkoutStripe();
let first;
stripe.checkout.sessions.create = async (params, options) => { first = { params, options }; throw new Error("connection lost"); };
try { await createCheckout(owner, "author_monthly", "", stripe); } catch (e) { expect(e.message).to.equal("connection lost"); }
const saved = await BillingAccount.findById(owner);
expect(saved.checkout.id).to.equal(undefined);
stripe.checkout.sessions.create = async (params, options) => {
expect(params).to.deep.equal(first.params);
expect(options).to.deep.equal(first.options);
return { id: "cs_recovered", url: "https://checkout.stripe.com/recovered" };
};
await createCheckout(owner, "author_monthly", "", stripe);
expect((await BillingAccount.findById(owner)).checkout.id).to.equal("cs_recovered");
});
it("recovers an old attempt by metadata without creating another payment", async () => {
await BillingAccount.updateOne({ _id: owner }, { $set: { checkout: { key: "attempt", planId: "author_monthly", target: "", createdAt: new Date(0) } } });
const stripe = checkoutStripe();
stripe.checkout.sessions.list = () => iterable([{ id: "cs_recovered", metadata: { attempt: "attempt", owner } }]);
expect(await createCheckout(owner, "author_monthly", "", stripe)).to.equal("https://checkout.stripe.com/recovered");
});
it("blocks unresolved old requests instead of reusing a pruned key", async () => {
await BillingAccount.updateOne({ _id: owner }, { $set: { checkout: { key: "old", planId: "author_monthly", target: "", createdAt: new Date(0) } } });
try { await createCheckout(owner, "author_monthly", "", checkoutStripe()); throw new Error("expected rejection"); }
catch (e) { expect(e.message).to.equal("billing_recovery_required"); }
});
it("checks subsequent subscription pages before offering another subscription", async () => {
const stripe = checkoutStripe();
stripe.subscriptions.list = async ({ starting_after }) => starting_after
? { data: [{ id: "active", status: "active" }], has_more: false }
: { data: [{ id: "old", status: "canceled" }], has_more: true };
try { await createCheckout(owner, "author_monthly", "", stripe); throw new Error("expected rejection"); }
catch (e) { expect(e.message).to.equal("billing_subscription_exists"); }
});
it("stops new purchases independently of event processing", async () => {
config.BILLING_CHECKOUT_ENABLED = false;
try { await createCheckout(owner, "author_monthly", "", checkoutStripe()); throw new Error("expected rejection"); }
catch (e) { expect(e.message).to.equal("billing_checkout_disabled"); }
await receiveBillingEvent(event("evt_existing"));
await processBillingInbox(1, async () => {});
expect((await BillingEvent.findOne()).state).to.equal("complete");
});
});
+51
View File
@@ -0,0 +1,51 @@
const { expect } = require("chai");
require("ts-node/register/transpile-only");
const { usableGrant } = require("../src/core/billing/quotas");
const { billingOrigin } = require("../src/core/billing/config");
const config = require("../src/config").default;
const { applyConferenceForm } = require("../src/server/routes/conference");
describe("paid access policy", () => {
it("requires paid time and rejects revoked, unpaid, cancelled and expired grants", () => {
const until = new Date(Date.now() + 60000);
for (const status of ["active", "paid", "past_due"]) expect(usableGrant({ status, until })).to.equal(true);
for (const status of ["incomplete", "unpaid", "canceled", "paused", "trialing"]) expect(usableGrant({ status, until })).to.equal(false);
expect(usableGrant({ status: "active", until: new Date(0) })).to.equal(false);
expect(usableGrant({ status: "active", until, revoked: true })).to.equal(false);
});
it("does not activate a paid conference from a submitted form or an edit", () => {
const model = {};
const body = { plan: { planID: "conference" }, startDate: "2026-11-01", endDate: "2027-01-01" };
applyConferenceForm(model, body, true);
expect(model.status).to.equal("pending_payment");
applyConferenceForm(model, body, false);
expect(model.status).to.equal("pending_payment");
model.status = "removed";
applyConferenceForm(model, body, false);
expect(model.status).to.equal("removed");
});
it("requires a finite dated expiration when billing is enabled", () => {
const { validateBillingExpiration } = require("../src/core/billing/expiration");
const enabled = config.BILLING_ENABLED;
config.BILLING_ENABLED = true;
try {
for (const options of [{}, { expirationMode: "remove" }, { expirationMode: "remove", expirationDate: "invalid" },
{ expirationMode: "remove", expirationDate: "2099-01-01" }]) {
expect(() => validateBillingExpiration(options)).to.throw("invalid_billing_expiration");
}
expect(() => validateBillingExpiration({ expirationMode: "never" })).not.to.throw();
expect(() => validateBillingExpiration({ expirationMode: "remove", expirationDate: new Date() })).not.to.throw();
} finally { config.BILLING_ENABLED = enabled; }
});
it("rejects unsafe configured redirect origins", () => {
const original = config.BILLING_ORIGIN;
try {
for (const value of ["http://example.org", "https://user:pass@example.org", "https://example.org/path", "https://example.org?next=x"]) {
config.BILLING_ORIGIN = value;
expect(() => billingOrigin()).to.throw();
}
config.BILLING_ORIGIN = "https://example.org";
expect(billingOrigin()).to.equal("https://example.org");
} finally { config.BILLING_ORIGIN = original; }
});
});
+4 -2
View File
@@ -3,6 +3,7 @@ const fs = require("fs");
const vm = require("vm");
const path = require("path");
const { setImmediate } = require("timers");
const { URLSearchParams } = require("node:url");
const source = fs.readFileSync(path.join(__dirname, "../public/script/app.js"), "utf8");
function harness(date) {
@@ -11,7 +12,7 @@ function harness(date) {
const context = { reactive: value => value, console, Map, Set, Date: date || Date,
formDraft: state => ({ restore: () => false, clear() {}, discard() {}, checkpoint: () => ({ terms: state.terms, options: state.options }), accept() {} }),
navigator: { platform: "Linux" }, document: { location: { pathname: "/r/repo" }, addEventListener() {}, querySelector() {} },
window: {}, Prism: { highlightAll() {} }, encodeURIComponent,
URLSearchParams, window: { location: { search: "" } }, Prism: { highlightAll() {} }, encodeURIComponent,
encodePathForUrl: p => p.split("/").map(encodeURIComponent).join("/"),
humanFileSize: x => String(x), parseGithubUrl: () => ({ owner: "owner", repo: "repo" }),
$: () => ({ on() {}, tooltip() {} }),
@@ -20,6 +21,7 @@ function harness(date) {
};
context.createTimers = () => ({ timeout: Object.assign(context.setTimeout, { cancel: context.clearTimeout }), interval: Object.assign(context.setInterval, { cancel: context.clearInterval }) });
context.createListeners = () => (target, name, callback) => target.addEventListener(name, callback);
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../public/script/billing.js"), "utf8").replaceAll("export function", "function"), context);
const names = [...source.matchAll(/export const (\w+)/g)].map(match => match[1]);
vm.runInNewContext(source.replace(/^import .*;$/gm, "").replace(/export const/g, "var") + "\nthis.pageSetups = {" + names.join(",") + "};", context);
Object.assign(defs, context.pageSetups);
@@ -59,7 +61,7 @@ describe("frontend production regressions", function () {
for (const id of ["repoId", "pullRequestId", "gistId"]) {
it(`preserves saved options when reopening a conference-linked ${id}`, async function () {
const h = anonymize({ [id]: "saved" });
h.requests[0].resolve({ data: { options: { update: true } } }); await h.flush();
h.requests.find(r => r.url === "/api/user/default").resolve({ data: { options: { update: true } } }); await h.flush();
h.requests.at(-1).resolve({ data: {
source: { fullName: "owner/repo", repositoryFullName: "owner/repo", branch: "main", commit: "abcdef", gistId: "123", pullRequestId: 1 },
conference: "conf",
+251 -2
View File
@@ -80,6 +80,126 @@ describe("Vue 3 UI", function () {
let ui;
afterEach(() => ui?.close());
it("keeps defaults across settings tabs and preserves the hidden fetch preference", async () => {
ui = await browser("/profile", {
"/api/user": { username: "owner" },
"/api/user/default": { terms: ["Original"], options: { mode: "GitHubDownload", link: true } },
});
expect(ui.window.document.querySelector("#mode")).to.equal(null);
await ui.input("#terms", "Changed Author");
ui.window.document.querySelectorAll(".settings-nav button")[1].click();
await delay(300);
ui.window.document.querySelectorAll(".settings-nav button")[0].click();
await delay(10);
expect(ui.window.document.querySelector("#terms").value).to.equal("Changed Author");
ui.window.document.querySelector("#save").click(); await delay(30);
const saved = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/user/default");
expect(saved.payload.options.mode).to.equal("GitHubDownload");
expect(saved.payload.terms).to.deep.equal(["Changed Author"]);
expect(ui.errors).to.deep.equal([]);
});
it("shows paid-through access and billing management without offering a duplicate subscription", async () => {
ui = await browser("/profile", {
"/api/user": { username: "owner" },
"/api/billing/": { enabled: true, csrf: "token", portal: true, allowance: { repositories: 20, storage: 10737418240 },
grants: [{ planId: "author_monthly", scope: "user:owner", status: "active", until: "2099-12-01", cancelAtPeriodEnd: true }],
plans: [{ id: "author_monthly", amount: 500, currency: "eur", interval: "month" }] },
});
const panel = ui.window.document.querySelector("#settings-billing");
expect(panel.textContent).to.include("Renewal cancelled");
expect(panel.textContent).to.include("Manage billing");
expect(panel.textContent).not.to.include("View Author plan");
expect(panel.querySelector(".billing-plan")).to.equal(null);
expect(ui.window.document.querySelector(".nav-upgrade")).to.equal(null);
expect(ui.errors).to.deep.equal([]);
});
it("requires the account name before submitting account deletion", async () => {
ui = await browser("/profile", { "/api/user": { username: "owner" }, "/api/user/": {} });
ui.window.document.querySelectorAll(".settings-nav button")[2].click(); await delay(10);
ui.window.document.querySelector(".paper-danger-zone button").click(); await delay(10);
const confirmation = ui.window.document.querySelector("#delete-username");
const button = ui.window.document.querySelector(".settings-delete-confirm .btn-outline-danger");
expect(button.disabled).to.equal(true);
confirmation.value = "wrong"; confirmation.dispatchEvent(new ui.window.Event("input", { bubbles: true })); await delay(10);
expect(button.disabled).to.equal(true);
confirmation.value = "owner"; confirmation.dispatchEvent(new ui.window.Event("input", { bubbles: true })); await delay(10);
expect(button.disabled).to.equal(false);
expect(ui.requests.some(r => r.method === "DELETE")).to.equal(false);
expect(ui.errors).to.deep.equal([]);
});
it("opens the plan comparison from the global header on another page", async () => {
ui = await browser("/dashboard", {
"/api/user": { username: "author" },
"/api/billing/": { enabled: true, csrf: "token", grants: [], allowance: { repositories: 5, storage: 2147483648 }, plans: [{ id: "author_monthly", amount: 500, currency: "eur", interval: "month", repositories: 20, storage: 10737418240 }] },
});
const upgrade = ui.window.document.querySelector(".app-header .nav-upgrade");
expect(upgrade).not.to.equal(null);
expect(ui.window.document.querySelector(".nav-support")).not.to.equal(null);
upgrade.click(); await delay(100);
expect(ui.window.document.querySelector(".settings-comparison")).not.to.equal(null);
expect(ui.window.document.querySelectorAll(".settings-nav button")[1].getAttribute("aria-current")).to.equal("page");
expect(ui.errors).to.deep.equal([]);
});
it("shows subscription cadence and sends a protected checkout request", async () => {
ui = await browser("/profile", {
"/api/user": { username: "author" },
"/api/billing/": { enabled: true, csrf: "billing-token", portal: true, grants: [], allowance: { repositories: 3, storage: 512 }, plans: [
{ id: "author_monthly", name: "Author monthly", description: "Renews monthly. Cancel renewal at any time.", amount: 500, currency: "eur", interval: "month", intervalCount: 1, repositories: 20, storage: 10737418240 },
] },
"/api/billing/checkout": { __status: 409, body: { error: "billing_subscription_exists" } },
});
ui.window.document.querySelector(".nav-upgrade").click();
await delay(10);
expect(ui.window.document.activeElement.id).to.equal("settings-plan-comparison");
expect(ui.window.document.querySelector(".settings-comparison").textContent).to.include("Scheduled or never");
expect(ui.window.document.querySelectorAll(".settings-nav button")[1].getAttribute("aria-current")).to.equal("page");
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("/ month");
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("10 GiB");
ui.window.document.querySelector(".billing-plan button").click();
await delay(30);
const request = ui.requests.find(r => r.url.pathname === "/api/billing/checkout");
expect(request.payload).to.deep.equal({ planId: "author_monthly", target: "" });
expect(request.headers["x-csrf-token"]).to.equal("billing-token");
expect(ui.window.document.querySelector("#settings-billing [role=alert]").textContent).to.include("already have a subscription");
expect(ui.errors).to.deep.equal([]);
});
it("refreshes the quota panel when checkout confirmation changes the allowance", async () => {
let confirmed = false;
let quotaReads = 0;
ui = await browser("/profile?billing=success", {
"/api/user": { username: "author" },
"/api/billing/": () => ({ enabled: true, csrf: "billing-token", grants: [], plans: [], allowance: { repositories: confirmed ? 20 : 5, storage: confirmed ? 10737418240 : 2147483648 } }),
"/api/user/quota": () => { quotaReads++; return { repository: { used: 1, total: confirmed ? 20 : 5 }, storage: { used: 128, total: confirmed ? 10737418240 : 2147483648 } }; },
"/api/billing/refresh": async () => { await delay(60); confirmed = true; return { ok: true, pending: false }; },
});
await delay(150);
expect(quotaReads).to.equal(2);
expect(ui.window.document.querySelector("#settings-billing").textContent).to.include("20 repositories");
expect(ui.window.document.querySelector("#settings-quota").textContent).to.match(/\/\s*20/);
expect(ui.errors).to.deep.equal([]);
});
it("offers payment for a pending conference without activating it in the browser", async () => {
ui = await browser("/conference/CONF", {
"/api/user": { username: "chair" },
"/api/conferences/CONF": { conferenceID: "CONF", name: "Research conference", status: "pending_payment", repositories: [], plan: { planID: "conference" } },
"/api/billing/": { enabled: true, csrf: "billing-token", grants: [], plans: [{ id: "conference", amount: 19900, currency: "eur", repositories: 100, storage: 1000 }] },
"/api/billing/checkout": { __status: 400, body: { error: "billing_invalid_conference" } },
});
const section = ui.window.document.querySelector('[aria-labelledby="conference-payment-heading"]');
expect(section.textContent).to.include("waiting for payment");
section.querySelector("button").click();
await delay(30);
expect(ui.requests.find(r => r.url.pathname === "/api/billing/checkout").payload).to.deep.equal({ planId: "conference", target: "CONF" });
expect(section.textContent).to.include("Only a conference owner");
expect(ui.errors).to.deep.equal([]);
});
const repositorySource = {
"/api/user": { username: "owner" },
"/api/user/default": { terms: ["Default Author"], options: {} },
@@ -89,6 +209,135 @@ describe("Vue 3 UI", function () {
"/api/anonymize-preview": request => ({ content: request.payload.content.replaceAll("Private Author", "MASKED") }),
};
const taskBilling = { enabled: true, csrf: "token", allowance: { plan: "free", repositories: 5 }, plans: [
{ id: "author_monthly", amount: 500, currency: "eur", interval: "month", repositories: 20, storage: 10737418240, description: "Renews monthly." },
] };
it("saves the repository draft before contextual checkout and preserves a failed checkout", async () => {
ui = await browser("/anonymize", { ...repositorySource,
"/api/billing/": taskBilling, "/api/user/quota": { repository: { used: 5, total: 5 } },
"/api/billing/draft": { id: "saved-task" },
"/api/billing/checkout": { __status: 503, body: { error: "billing_unavailable" } },
});
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state;
state.sourceUrl = "https://github.com/owner/repo";
await state.urlSelected(); await delay(60);
await ui.input("#terms", "Uncommitted redaction");
await state.checkoutRepository();
const saved = ui.requests.find(r => r.method === "POST" && r.url.pathname === "/api/billing/draft");
expect(saved.payload.path).to.equal("/anonymize");
expect(saved.payload.values.terms).to.equal("Uncommitted redaction");
const checkout = ui.requests.find(r => r.url.pathname === "/api/billing/checkout");
expect(checkout.payload.draftId).to.equal("saved-task");
expect(state.terms).to.equal("Uncommitted redaction");
expect(state.billingError).to.include("not available");
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
expect(ui.errors).to.deep.equal([]);
});
it("restores a paid checkout task without submitting it and rechecks the allowance", async () => {
ui = await browser("/anonymize?billingDraft=saved-task&billing=success", { ...repositorySource,
"/api/billing/": { ...taskBilling, allowance: { plan: "author_monthly", repositories: 20 } },
"/api/billing/refresh": { ok: true, pending: false },
"/api/billing/draft/saved-task": { id: "saved-task", path: "/anonymize", values: { sourceUrl: "https://github.com/owner/repo", terms: "Restored Author", repoId: "saved-repo", source: { branch: "main" }, options: { expirationMode: "never" } } },
});
await delay(150);
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.terms).to.equal("Restored Author");
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.repoId).to.equal("saved-repo");
expect(ui.app.router.currentRoute.value.query).to.deep.equal({});
expect(ui.window.document.querySelector("#sourceUrl")._field.binding.state.hostingNeedsPayment()).to.equal(false);
expect(ui.window.document.querySelector("#repository-hosting").textContent).to.include("subscription is active");
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
expect(ui.errors).to.deep.equal([]);
});
it("keeps all settings and the live preview visible in the split workspace", async () => {
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling,
"/api/user/quota": { repository: { used: 5, total: 5 } } });
const doc = ui.window.document;
const state = doc.querySelector("#sourceUrl")._field.binding.state;
state.sourceUrl = "https://github.com/owner/repo";
await state.urlSelected(); await delay(60);
expect(doc.querySelector('.repository-task')).to.equal(null);
for (const selector of ['#sourceUrl', '#terms', '#image', '#conference', '#expirationDate']) {
expect(doc.querySelector(selector).closest('details')).to.equal(null);
}
state.terms = "Private Author";
await delay(450);
expect(doc.querySelector('.anonymize-preview-body').textContent).to.include("MASKED");
expect(doc.querySelector('.anonymize-preview-body').textContent).not.to.include("Private Author");
expect(doc.querySelector('.subscription-choice').textContent).to.include("20 repositories");
expect(doc.querySelector('.anonymize-submit-bar').textContent).to.include("Continue to checkout");
doc.querySelector('.preview-expand').click(); await delay(10);
expect(doc.querySelector('.anonymize-page').classList.contains('preview-expanded')).to.equal(true);
doc.querySelector('.preview-expand').click(); await delay(10);
expect(state.terms).to.equal("Private Author");
expect(ui.requests.some(r => r.method === "POST" && r.url.pathname === "/api/repo/")).to.equal(false);
expect(ui.errors).to.deep.equal([]);
});
it("keeps checkout failures and pending payment in the editor without losing input", async () => {
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling,
"/api/user/quota": { repository: { used: 5, total: 5 } },
"/api/billing/draft": { id: "saved-task" },
"/api/billing/checkout": { __status: 503, body: { error: "billing_unavailable" } } });
const doc = ui.window.document;
const state = doc.querySelector("#sourceUrl")._field.binding.state;
state.sourceUrl = "https://github.com/owner/repo";
await state.urlSelected(); await delay(60);
expect(doc.querySelector("#expirationDate").value).to.match(/^\d{4}-\d{2}-\d{2}$/);
await ui.input("#terms", "Preserved Author");
doc.querySelector(".anonymize-submit-bar button").click(); await delay(80);
expect(doc.querySelector("#repository-hosting").textContent).to.include("Payments are not available");
expect(state.terms).to.equal("Preserved Author");
expect(ui.requests.find(r => r.url.pathname === "/api/billing/draft").payload.values.terms).to.equal("Preserved Author");
state.billingPending = true; await delay(20);
expect(doc.querySelector("#repository-hosting").textContent).to.include("Confirming your payment");
expect(doc.querySelector(".subscription-choice")).to.equal(null);
expect(doc.querySelector(".anonymize-submit-bar button").textContent).to.equal("Check payment status");
const checkoutCount = ui.requests.filter(r => r.url.pathname === "/api/billing/checkout").length;
await state.submitRepository({});
expect(ui.requests.filter(r => r.url.pathname === "/api/billing/checkout")).to.have.length(checkoutCount);
expect(ui.errors).to.deep.equal([]);
});
for (const status of ["preparing", "ready", "error"]) {
it(`keeps the task summary through repository ${status} without a premature ready link`, async () => {
ui = await browser("/status/saved", { "/api/repo/saved": { repoId: "saved", status,
source: { fullName: "owner/repo" }, options: { expirationMode: "never" } } });
const doc = ui.window.document;
expect(doc.querySelector('.task-trail [aria-current="step"]').textContent).to.include("Review link");
expect(doc.querySelector(".task-summary").textContent).to.include("owner/repo");
expect(!!doc.querySelector(".task-ready")).to.equal(status === "ready");
expect(!!doc.querySelector('.task-preparation a[href="/r/saved/"]')).to.equal(status === "ready");
expect(ui.errors).to.deep.equal([]);
});
}
it("keeps free, conference and coauthor work out of author checkout", async () => {
ui = await browser("/anonymize", { ...repositorySource, "/api/billing/": taskBilling });
const state = ui.window.document.querySelector("#sourceUrl")._field.binding.state; state.detectedType = "repo";
state.hostingQuota = { repository: { used: 1, total: 5 } };
expect(state.hostingNeedsPayment()).to.equal(false);
state.hostingQuota.repository.used = 5;
expect(state.hostingNeedsPayment()).to.equal(true);
state.conference = "CONF";
expect(state.hostingNeedsPayment()).to.equal(false);
state.options.expirationMode = "never";
expect(state.hostingNeedsPayment()).to.equal(true);
state.isUpdate = true; state.role = "coauthor";
expect(state.hostingNeedsPayment()).to.equal(false);
});
it("refreshes the conference in place after checkout confirmation", async () => {
let confirmed = false;
ui = await browser("/conference/CONF?billing=success", { "/api/user": { username: "chair" },
"/api/billing/": taskBilling,
"/api/billing/refresh": () => { confirmed = true; return { ok: true, pending: false }; },
"/api/conferences/CONF": () => ({ conferenceID: "CONF", name: "Conference", status: confirmed ? "ready" : "pending_payment", repositories: [] }),
});
await delay(80);
expect(ui.window.document.body.textContent).to.include("Ready");
expect(ui.window.document.querySelector('[aria-labelledby="conference-payment-heading"]')).to.equal(null);
expect(ui.errors).to.deep.equal([]);
});
it("keeps the current repository when earlier metadata and README responses arrive late", async () => {
let finishMetadata, finishReadme;
ui = await browser("/anonymize", {
@@ -309,7 +558,7 @@ describe("Vue 3 UI", function () {
[...preview().querySelectorAll("button")].find(b => b.textContent === "Retry preview").click();
await delay(260);
expect(preview().textContent).to.include("MASKED");
expect(preview().textContent).to.include("redactions applied");
expect(preview().querySelector(".anonymize-preview-body")).not.to.equal(null);
expect(ui.errors).to.deep.equal([]);
});
}
@@ -756,7 +1005,7 @@ describe("Vue 3 UI", function () {
"/api/repo/owner/repo/branches": [{ name: "main", commit: "abcdef123" }],
"/api/repo/owner/repo/readme": "",
});
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes("Update "));
const button = [...ui.window.document.querySelectorAll("button")].find(b => b.textContent.includes(type === "repo" ? "Save changes" : "Update "));
const posts = () => ui.requests.filter(r => r.method === "POST" && r.url.pathname === endpoint);
button.click();
await delay(10);
+1 -1
View File
@@ -43,7 +43,7 @@ describe("ZIP proxy failures", function () {
return http.get(`http://127.0.0.1:${server.address().port}/test/zip`, { headers }, onResponse);
}
it("answers HEAD without counting a view, obtaining a token or starting a ZIP stream", async function () {
utils.getRepo = async () => ({ repoId: "test", countView: () => { throw Error("view counted"); },
utils.getRepo = async () => ({ repoId: "test", model: {}, countView: () => { throw Error("view counted"); },
getToken: () => { throw Error("token requested"); }, generateAnonymizeTransformer: () => { throw Error("transform requested"); } });
got.stream = () => { throw Error("archive requested"); };
const result = await new Promise((resolve, reject) => {