mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 08:17:26 +01:00
191 lines
13 KiB
JavaScript
191 lines
13 KiB
JavaScript
const { setTimeout } = require("node:timers");
|
|
// Explicit opt-in integration test. Creates only tagged sandbox objects.
|
|
// Credentials live outside the repository; never log Stripe errors verbatim.
|
|
const fs = require("node:fs");
|
|
const assert = require("node:assert/strict");
|
|
const crypto = require("node:crypto");
|
|
require("ts-node/register/transpile-only");
|
|
const mongoose = require("mongoose");
|
|
const Stripe = require("stripe");
|
|
const config = require("../src/config").default;
|
|
const { BillingAccount, BillingGrant, BillingQuota, BillingLock } = 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 { createCheckout, syncSubscription, fulfillCheckout, cancelAccountBilling } = require("../src/core/billing/stripe");
|
|
const { allowance } = require("../src/core/billing/quotas");
|
|
const { neverExpirationDeadline, reconcileNeverExpiration } = require("../src/core/billing/expiration");
|
|
const run = crypto.randomUUID();
|
|
const metadata = { application: "anonymous-github", sandbox_test_run: run };
|
|
const results = [];
|
|
const customers = [], prices = [], products = [], clocks = [];
|
|
let stripe;
|
|
async function check(name, fn) { await fn(); results.push(name); console.log("PASS " + name); }
|
|
async function customer() {
|
|
const owner = new mongoose.Types.ObjectId().toString();
|
|
const c = await stripe.customers.create({ metadata: { ...metadata, owner }, payment_method: "pm_card_visa", invoice_settings: { default_payment_method: "pm_card_visa" } });
|
|
customers.push(c.id);
|
|
await BillingAccount.create({ _id: owner, customerId: c.id });
|
|
return { owner, id: c.id };
|
|
}
|
|
async function main() {
|
|
const credentialsPath = process.env.STRIPE_SANDBOX_CREDENTIALS;
|
|
if (!credentialsPath) throw new Error("Set STRIPE_SANDBOX_CREDENTIALS to a private JSON file");
|
|
const { secret } = JSON.parse(fs.readFileSync(credentialsPath, "utf8"));
|
|
if (typeof secret !== "string" || !secret.startsWith("sk_test_")) throw new Error("Sandbox secret key required");
|
|
stripe = new Stripe(secret, { maxNetworkRetries: 2, timeout: 20000 });
|
|
assert.equal((await stripe.balance.retrieve()).livemode, false);
|
|
const uri = process.env.BILLING_TEST_MONGODB_URI;
|
|
assert.match(new URL(uri).pathname, /^\/billing_test_/);
|
|
await mongoose.connect(uri);
|
|
await Promise.all([BillingAccount, BillingGrant, BillingQuota, BillingLock, Repo, Conference].map(m => m.init()));
|
|
Object.assign(config, { BILLING_ENABLED: true, STRIPE_SECRET_KEY: secret,
|
|
STRIPE_WEBHOOK_SECRET: crypto.randomBytes(32).toString("hex"), BILLING_ORIGIN: "http://localhost:5183", BILLING_AUTOMATIC_TAX: false });
|
|
const product = await stripe.products.create({ name: "Anonymous GitHub sandbox integration " + run, metadata });
|
|
products.push(product.id);
|
|
for (const [key, amount, interval] of [
|
|
["STRIPE_AUTHOR_MONTHLY_PRICE",500,"month"],["STRIPE_AUTHOR_ANNUAL_PRICE",4900,"year"],
|
|
["STRIPE_AUTHOR_PASS_PRICE",1500],["STRIPE_CONFERENCE_PRICE",19900]]) {
|
|
const p = await stripe.prices.create({ product: product.id, currency: "eur", unit_amount: amount, metadata, ...(interval ? { recurring: { interval } } : {}) });
|
|
prices.push(p.id); config[key] = p.id;
|
|
}
|
|
const buyer = await customer();
|
|
await Repo.create({ repoId: "sandbox-"+run, owner: buyer.owner, status: "ready" });
|
|
await Conference.create({ conferenceID: "sandbox-"+run, owners: [buyer.owner], status: "pending_payment",
|
|
startDate: new Date(), endDate: new Date(Date.now()+86400000*30), plan: { planID: "conference" } });
|
|
for (const plan of ["author_monthly","author_annual","author_pass","conference"]) {
|
|
await check(plan + " Checkout creation, reuse, unpaid denial and expiry", async () => {
|
|
const target = ["author_pass","conference"].includes(plan) ? "sandbox-"+run : "";
|
|
const url = await createCheckout(buyer.owner, plan, target, stripe);
|
|
assert.match(url, /^https:\/\/checkout.stripe.com\//);
|
|
assert.equal(await createCheckout(buyer.owner,plan,target,stripe), url);
|
|
const account = await BillingAccount.findById(buyer.owner);
|
|
await fulfillCheckout(account.checkout.id, stripe);
|
|
assert.equal(await BillingGrant.countDocuments({ owner: buyer.owner }),0);
|
|
await stripe.checkout.sessions.expire(account.checkout.id);
|
|
});
|
|
}
|
|
for (const [plan,key] of [["author_monthly","STRIPE_AUTHOR_MONTHLY_PRICE"],["author_annual","STRIPE_AUTHOR_ANNUAL_PRICE"]]) {
|
|
const c = await customer();
|
|
const sub = await stripe.subscriptions.create({ customer:c.id, items:[{price:config[key]}], metadata:{...metadata,owner:c.owner} });
|
|
await check(plan + " successful payment and entitlement",async()=>{
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.equal((await allowance(c.owner)).plan,plan);
|
|
assert.ok(await neverExpirationDeadline(c.owner));
|
|
});
|
|
await check(plan + " duplicate subscription prevention",async()=>{
|
|
await assert.rejects(createCheckout(c.owner,plan,"",stripe),/billing_subscription_exists/);
|
|
});
|
|
await check(plan + " cancellation preserves paid period",async()=>{
|
|
await stripe.subscriptions.update(sub.id,{cancel_at_period_end:true});
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.equal((await allowance(c.owner)).plan,plan);
|
|
assert.equal((await BillingGrant.findById(sub.id)).cancelAtPeriodEnd,true);
|
|
});
|
|
await check(plan + " immediate cancellation and grace expiry",async()=>{
|
|
const deadline = await neverExpirationDeadline(c.owner);
|
|
await Repo.create({repoId:plan+"-"+run,owner:c.owner,status:"ready",options:{expirationMode:"never"},billingNeverExpiresUntil:deadline});
|
|
await stripe.subscriptions.cancel(sub.id);
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.equal((await allowance(c.owner)).plan,"free");
|
|
await reconcileNeverExpiration(new Date(deadline.getTime()-1));
|
|
assert.equal((await Repo.findOne({owner:c.owner})).options.expirationMode,"never");
|
|
await reconcileNeverExpiration(deadline);
|
|
assert.equal((await Repo.findOne({owner:c.owner})).options.expirationMode,"remove");
|
|
});
|
|
}
|
|
await check("declined initial payment grants no paid access",async()=>{
|
|
const c=await customer();
|
|
const pm=await stripe.paymentMethods.attach("pm_card_chargeCustomerFail",{customer:c.id});
|
|
await stripe.customers.update(c.id,{invoice_settings:{default_payment_method:pm.id}});
|
|
const sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],payment_behavior:"default_incomplete",metadata:{...metadata,owner:c.owner}});
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.equal((await allowance(c.owner)).plan,"free");
|
|
await assert.rejects(neverExpirationDeadline(c.owner),/subscription_required/);
|
|
});
|
|
|
|
await check("subscription refund revocation, signed event replay and tamper rejection",async()=>{
|
|
const c=await customer();
|
|
const sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],metadata:{...metadata,owner:c.owner}});
|
|
await syncSubscription(sub.id,stripe);
|
|
const payments=await stripe.invoicePayments.list({invoice:sub.latest_invoice});
|
|
const pi=payments.data.find(p=>p.payment.type==="payment_intent").payment.payment_intent;
|
|
const refund=await stripe.refunds.create({payment_intent:typeof pi==="string"?pi:pi.id});
|
|
const charge=await stripe.charges.retrieve(refund.charge);
|
|
const express=require("express");
|
|
const app=express();
|
|
app.use("/webhook",require("../src/server/routes/billing").billingWebhook);
|
|
const server=await new Promise(resolve=>{const srv=app.listen(0,"127.0.0.1",()=>resolve(srv));});
|
|
try {
|
|
// Real Stripe charge payload; local signature exercises the production HTTP route.
|
|
const payload=JSON.stringify({id:"evt_sandbox_"+run,type:"charge.refunded",data:{object:charge}});
|
|
const signature=stripe.webhooks.generateTestHeaderString({payload,secret:config.STRIPE_WEBHOOK_SECRET});
|
|
const post=body=>globalThis.fetch("http://127.0.0.1:"+server.address().port+"/webhook",{method:"POST",headers:{"content-type":"application/json","stripe-signature":signature},body});
|
|
assert.equal((await post(payload)).status,200);
|
|
assert.equal((await post(payload)).status,200);
|
|
assert.equal((await post(payload+" ")).status,400);
|
|
await require("../src/core/billing/events").processBillingInbox();
|
|
assert.equal((await allowance(c.owner)).plan,"free");
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.equal((await allowance(c.owner)).plan,"free");
|
|
} finally { server.closeAllConnections();await new Promise(resolve=>server.close(resolve)); }
|
|
});
|
|
await check("subscription renewal, failed renewal, and recovery using a Stripe test clock",async()=>{
|
|
const clock=await stripe.testHelpers.testClocks.create({frozen_time:Math.floor(Date.now()/1000),name:"Anonymous GitHub "+run});
|
|
clocks.push(clock.id);
|
|
const owner=new mongoose.Types.ObjectId().toString();
|
|
const c=await stripe.customers.create({test_clock:clock.id,payment_method:"pm_card_visa",invoice_settings:{default_payment_method:"pm_card_visa"},metadata:{...metadata,owner}});
|
|
customers.push(c.id);await BillingAccount.create({_id:owner,customerId:c.id});
|
|
let sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],metadata:{...metadata,owner}});
|
|
await syncSubscription(sub.id,stripe);
|
|
const original=(await BillingGrant.findById(sub.id)).until.getTime();
|
|
async function advance(time) {
|
|
await stripe.testHelpers.testClocks.advance(clock.id,{frozen_time:time});
|
|
for(let i=0;i<60;i++) {
|
|
if((await stripe.testHelpers.testClocks.retrieve(clock.id)).status==="ready") return;
|
|
await new Promise(r=>setTimeout(r,1000));
|
|
}
|
|
throw new Error("Test clock did not settle");
|
|
}
|
|
await advance(sub.items.data[0].current_period_end+1);
|
|
sub=await stripe.subscriptions.retrieve(sub.id);
|
|
let invoice=await stripe.invoices.retrieve(sub.latest_invoice);
|
|
if(invoice.status==="draft") invoice=await stripe.invoices.finalizeInvoice(invoice.id);
|
|
if(invoice.status==="open") await stripe.invoices.pay(invoice.id);
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.ok((await BillingGrant.findById(sub.id)).until.getTime()>original);
|
|
const paidThrough=(await BillingGrant.findById(sub.id)).until.getTime();
|
|
const failed=await stripe.paymentMethods.attach("pm_card_chargeCustomerFail",{customer:c.id});
|
|
await stripe.customers.update(c.id,{invoice_settings:{default_payment_method:failed.id}});
|
|
await advance(sub.items.data[0].current_period_end+1);
|
|
sub=await stripe.subscriptions.retrieve(sub.id);
|
|
invoice=await stripe.invoices.retrieve(sub.latest_invoice);
|
|
if(invoice.status==="draft") invoice=await stripe.invoices.finalizeInvoice(invoice.id);
|
|
try { await stripe.invoices.pay(invoice.id); } catch(e) { assert.equal(e.type,"StripeCardError"); }
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.equal((await BillingGrant.findById(sub.id)).until.getTime(),paidThrough);
|
|
// App wall time does not follow Stripe's test clock. Verify paid-through does not advance.
|
|
const good=await stripe.paymentMethods.attach("pm_card_visa",{customer:c.id});
|
|
await stripe.customers.update(c.id,{invoice_settings:{default_payment_method:good.id}});
|
|
await stripe.invoices.pay(invoice.id);
|
|
await syncSubscription(sub.id,stripe);
|
|
assert.ok((await BillingGrant.findById(sub.id)).until.getTime()>paidThrough);
|
|
});
|
|
await check("account deletion cancels subscription",async()=>{
|
|
const c=await customer();
|
|
const sub=await stripe.subscriptions.create({customer:c.id,items:[{price:config.STRIPE_AUTHOR_MONTHLY_PRICE}],metadata:{...metadata,owner:c.owner}});
|
|
await syncSubscription(sub.id,stripe);
|
|
await cancelAccountBilling(c.owner);
|
|
assert.equal((await stripe.subscriptions.retrieve(sub.id)).status,"canceled");
|
|
});
|
|
}
|
|
main().catch(e=>{console.error(JSON.stringify({failure:true,type:e.type||e.name,code:e.code,parameter:e.param,message:e.type?undefined:e.message}));process.exitCode=1;}).finally(async()=>{
|
|
if(stripe) {
|
|
for(const id of customers) try { await stripe.customers.del(id); } catch { console.error("Customer cleanup failed: "+id); }
|
|
for(const id of clocks) try { await stripe.testHelpers.testClocks.del(id); } catch { console.error("Clock cleanup failed: "+id); }
|
|
for(const id of prices) try { await stripe.prices.update(id,{active:false}); } catch { console.error("Price cleanup failed: "+id); }
|
|
for(const id of products) try { await stripe.products.update(id,{active:false}); } catch { console.error("Product cleanup failed: "+id); }
|
|
}
|
|
if(mongoose.connection.readyState) await mongoose.disconnect();
|
|
console.log(JSON.stringify({run,passed:results.length,results}));
|
|
});
|