diff --git a/system/netlify/functions/give-portal.js b/system/netlify/functions/give-portal.js index 02189bd2ad..7c1bfd4006 100644 --- a/system/netlify/functions/give-portal.js +++ b/system/netlify/functions/give-portal.js @@ -39,7 +39,7 @@ export async function handler(event, context) { return respond(500, { error: "Stripe not configured" }); } - const stripe = Stripe(stripeKey); + const stripe = new Stripe(stripeKey); try { const body = JSON.parse(event.body || "{}"); diff --git a/system/netlify/functions/give.js b/system/netlify/functions/give.js index 3a6769a935..ecdab2a478 100644 --- a/system/netlify/functions/give.js +++ b/system/netlify/functions/give.js @@ -33,7 +33,7 @@ export async function handler(event, context) { return respond(500, { error: "Stripe not configured" }); } - const stripe = Stripe(stripeKey); + const stripe = new Stripe(stripeKey); try { const body = JSON.parse(event.body || "{}"); diff --git a/system/public/give.aesthetic.computer/give.mjs b/system/public/give.aesthetic.computer/give.mjs index a014609a98..2d9e3c2a7b 100644 --- a/system/public/give.aesthetic.computer/give.mjs +++ b/system/public/give.aesthetic.computer/give.mjs @@ -86,7 +86,7 @@ $('form').addEventListener('submit', async (event) => { const data = await res.json(); if (!res.ok || !data.url) throw new Error('Checkout unavailable'); const url = new URL(data.url); - if (url.protocol !== 'https:' || url.hostname !== 'checkout.stripe.com') throw new Error('Invalid checkout URL'); + if (url.protocol !== 'https:' || !['checkout.stripe.com', 'pay.aesthetic.computer'].includes(url.hostname)) throw new Error('Invalid checkout URL'); location.assign(url.href); } catch { $('error').textContent = words[lang].error; diff --git a/system/tests/give-checkout-browser.test.mjs b/system/tests/give-checkout-browser.test.mjs new file mode 100644 index 0000000000..9d53abf665 --- /dev/null +++ b/system/tests/give-checkout-browser.test.mjs @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { chromium } from "playwright"; + +test("Give opens standard and custom Stripe checkout and rejects other destinations", async () => { + const browser = await chromium.launch({ headless: true, channel: process.env.PLAYWRIGHT_CHANNEL }); + try { + const context = await browser.newContext(); + let destination; + const submitted = []; + await context.route("**/*", async route => { + const request = route.request(), url = new URL(request.url()); + if (url.pathname === "/api/give") { + submitted.push(JSON.parse(request.postData())); + return route.fulfill({ json: { url: destination }, headers: { "Access-Control-Allow-Origin": "*" } }); + } + if (url.pathname === "/api/gives") + return route.fulfill({ json: { activeSubscribers: 5 }, headers: { "Access-Control-Allow-Origin": "*" } }); + if (url.hostname === "give.aesthetic.computer" && ["/", "/give.mjs"].includes(url.pathname)) + return route.fulfill({ contentType: url.pathname === "/" ? "text/html" : "text/javascript", + body: await readFile(new URL(`../public/give.aesthetic.computer/${url.pathname === "/" ? "index.html" : "give.mjs"}`, import.meta.url), "utf8") }); + return route.fulfill({ contentType: "text/html", body: "Checkout destination" }); + }); + const page = await context.newPage(); + for (const host of ["checkout.stripe.com", "pay.aesthetic.computer"]) { + destination = `https://${host}/test-checkout`; + await page.goto("https://give.aesthetic.computer/"); + await page.locator("#give").click(); + await page.waitForURL(destination); + assert.equal(submitted.at(-1).amount, 800); + assert.equal(submitted.at(-1).recurring, true); + } + for (const rejected of ["https://pay.aesthetic.computer.evil.test/", "http://pay.aesthetic.computer/"]) { + destination = rejected; + await page.goto("https://give.aesthetic.computer/"); + await page.locator('input[value="once"]').check(); + await page.locator("#give").click(); + await page.waitForFunction(() => document.getElementById("error").textContent.length > 0); + assert.equal(page.url(), "https://give.aesthetic.computer/"); + assert.equal(submitted.at(-1).recurring, false); + assert.equal(await page.locator("#give").isEnabled(), true); + } + await context.close(); + } finally { await browser.close(); } +}); diff --git a/system/tests/give-checkout.test.mjs b/system/tests/give-checkout.test.mjs new file mode 100644 index 0000000000..5a39b5e449 --- /dev/null +++ b/system/tests/give-checkout.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { handler } from "../netlify/functions/give.js"; + +test("Give constructs the installed Stripe client before validating amounts", async () => { + const key = process.env.CONTEXT === "dev" ? "STRIPE_API_TEST_PRIV_KEY" : "STRIPE_API_PRIV_KEY"; + const previous = process.env[key]; + process.env[key] = "sk_test_validation_only"; + try { + // Each request stops before Stripe network I/O; the current SDK is a class. + for (const recurring of [false, true]) { + const result = await handler({ httpMethod: "POST", headers: {}, + body: JSON.stringify({ amount: 50, currency: "usd", recurring }) }); + assert.equal(result.statusCode, 400); + assert.match(JSON.parse(result.body).error, /Invalid amount/); + } + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } +});