mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 13:29:44 +00:00
* badges: webapp (#7433) * badges: service migrations, store and catalog * badges: BTCPay provider and settlement poller * badges: web listener and /api endpoints * web: checkout single-page app * badges: tests and BTCPay fixtures * badges: README and ini reference * badges: fix hex16 build on GHC 8.10.7 * badges: Stripe card lane * badges: fix Stripe card checkout, add theming * badges: add a discount row to the order summary * badges: site navbar, embedding, theme, Forget move * badges: use SB code prefix in web checkout * badges: rename sxb app namespace to sb * badges: embed checkout nav via site; keep original app navbar * badges: post iframe height, apply site background when embedded * badges: embed dark surfaces, steadier iframe height * badges: hide app footer when embedded * badges: size embedded body to content, not viewport * badges: declare color-scheme to stop reload flash * badges: fade shell in on load, no reload blank * badges: prerender app shell into index.html * badges: pre-paint theme, hide shell on deep reload * badges: logo returns to landing client-side * badges: embedded wizard back, buy-a-code, resume * badges: signal app-managed screens, resume across reload * badges: rebuild wizard history on deep load so Back walks it * badges: carry welcome-page height as the iframe floor * badges: keep selection on Buy a code; rename to Your codes * badges: read web shell as UTF-8, not locale * badges: resume the exact paid order after Stripe card redirect * badges: move docker deploy under scripts * badges: add serve_webapp toggle and webapp export * badges: wire split webapp deploy in docker config * badges: quiet agent logs by default * badges: resume card redirect in the embedded frame * badges: migrate Stripe adapter to PaymentIntents * badges: correct Stripe restricted key scopes in ini example * badges: card via Payment Element and PaymentIntents * badges: fix stale Checkout Session wording in Stripe adapter * badges: fix stale CheckoutActions reference in card comment * badges: order shell stylesheet before bootstrap script * badges: remove development card stand-in * badges: theme the Stripe card form with the site palette * badges: exclude web from the Haskell build stage * badges: unify invoice cancel and mark canceled * badges: default log level to info * badges: unify closed-invoice buy-again button * badges: mute agent connection logs at info level * badges: show purchase time in local timezone in Your codes * badges: log service events on own channel, quiet agent * badges: fold service migrations into one baseline * badges: run compose on postgres over host network * badges: use high-res hero art * badges: add web CI to catch stale builds * badges: rebuild web shell from committed source * badges: normalize invoice-code link and columns * badges: drop unused columns, rename index * badges: note deferred receipt_hash in migrations * badges: apply code-review fixes * badges: reduce comments across service and web --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: improve web page (#7546) * badges: improve web page * improve layout * improve layout * fix * small changes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: read one issuer key from the ini * badges: move and group the service tests * badges: service fixes (#7567) * badges: match the redeem error wording in tests * badges: drop unused imports in the bot tests * badges: cancel Stripe orders when they expire * badges: correct the Stripe config and docs * badges: refuse to revoke a redeemed code * badges: make the fake Stripe cancel like Stripe * badges: limit replayed webhook deliveries --------- Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum <github.shum@liber.li> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
600 lines
29 KiB
TypeScript
600 lines
29 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { headingOf, installPage, inViewOf, primaryOf, settle, timedTest, until } from "./boot.js";
|
|
import { StubElement } from "./stub-dom.js";
|
|
|
|
const cardTest = timedTest(3000);
|
|
|
|
const page = installPage();
|
|
const { app, history, location, storage, fetches } = page;
|
|
|
|
const keyMeta = new StubElement("meta");
|
|
keyMeta.setAttribute("id", "stripe-publishable-key");
|
|
keyMeta.setAttribute("name", "stripe-publishable-key");
|
|
keyMeta.setAttribute("content", "");
|
|
page.document.byId.set("stripe-publishable-key", keyMeta);
|
|
function configureKey(key: string): void { keyMeta.setAttribute("content", key); }
|
|
|
|
const stripe = await import("../src/stripe.js");
|
|
const screens = await import("../src/screens.js");
|
|
await import("../src/main.js");
|
|
|
|
type UnpaidOrder = import("../src/order.js").UnpaidOrder;
|
|
type InvoiceView = import("../src/api.js").InvoiceView;
|
|
|
|
const HELD_CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP";
|
|
const CLIENT_SECRET = "cs_test_a1b2c3d4";
|
|
const PUBLISHABLE_KEY = "pk_test_51NotARealKey";
|
|
const APPEARANCE = { theme: "stripe" } as const;
|
|
const RETURN_URL = "https://badges.example/";
|
|
|
|
function render(node: unknown): StubElement { return node as unknown as StubElement; }
|
|
|
|
const order: UnpaidOrder = {
|
|
orderId: "inv_card", badgeType: "legend", months: 12,
|
|
createdAt: "2026-08-28T11:02:19Z", status: "open",
|
|
code: HELD_CODE,
|
|
} as unknown as UnpaidOrder;
|
|
|
|
const cardInvoice: InvoiceView = {
|
|
status: "open", amount: 42000, currency: "usd", clientSecret: CLIENT_SECRET,
|
|
};
|
|
|
|
function assertNoCode(node: StubElement, where: string): void {
|
|
const dump = node.serialize();
|
|
for (const form of [HELD_CODE, HELD_CODE.replace(/-/g, ""), "SB-"]) {
|
|
assert.ok(!dump.includes(form), `${where} leaked a code (${form})`);
|
|
}
|
|
}
|
|
|
|
cardTest("stripe: with no publishable key the card lane is unavailable, and nothing loads", () => {
|
|
for (const [what, key] of [["absent", undefined], ["empty", ""], ["whitespace", " "]] as const) {
|
|
const plan = stripe.cardPlan(key, false);
|
|
assert.equal(plan.kind, "unavailable", `a ${what} key configures no card form`);
|
|
assert.equal(plan.kind === "unavailable" ? plan.reason : "", "unconfigured");
|
|
}
|
|
});
|
|
|
|
cardTest("stripe: with a key configured the plan is to LOAD, and carries the key", () => {
|
|
const plan = stripe.cardPlan(` ${PUBLISHABLE_KEY} `, false);
|
|
assert.equal(plan.kind, "load");
|
|
assert.equal(plan.kind === "load" ? plan.publishableKey : "", PUBLISHABLE_KEY, "trimmed, as a config value is");
|
|
});
|
|
|
|
cardTest("stripe: offline with a key is a failure screen, not a form that never fills", () => {
|
|
const plan = stripe.cardPlan(PUBLISHABLE_KEY, true);
|
|
assert.equal(plan.kind, "unavailable");
|
|
assert.equal(plan.kind === "unavailable" ? plan.reason : "", "offline");
|
|
});
|
|
|
|
cardTest("stripe: a missing key wins over offline — the page is unconfigured either way", () => {
|
|
const plan = stripe.cardPlan("", true);
|
|
assert.equal(plan.kind, "unavailable");
|
|
assert.equal(plan.kind === "unavailable" ? plan.reason : "", "unconfigured");
|
|
});
|
|
|
|
cardTest("stripe: the script URL is Stripe's own origin, which may not be self-hosted", () => {
|
|
assert.ok(stripe.STRIPE_JS_URL.startsWith("https://js.stripe.com/"), stripe.STRIPE_JS_URL);
|
|
});
|
|
|
|
interface Trace {
|
|
calls: string[]; target: unknown; secret: string; key: string;
|
|
createArg: string;
|
|
appearance: string;
|
|
returnUrl: string;
|
|
destroys: number;
|
|
}
|
|
|
|
function fakeStripe(over: {
|
|
elementsFails?: boolean; mountThrows?: boolean; destroyThrows?: boolean;
|
|
confirmResult?: import("../src/stripe.js").PaymentIntentResult; confirmRejects?: boolean;
|
|
} = {}): { load: import("../src/stripe.js").LoadStripeJs; trace: Trace; loaded: string[] } {
|
|
const trace: Trace = { calls: [], target: null, secret: "", key: "", createArg: "", appearance: "", returnUrl: "", destroys: 0 };
|
|
const loaded: string[] = [];
|
|
const elements = {
|
|
create: (kind: "payment") => {
|
|
trace.calls.push("create");
|
|
trace.createArg = kind;
|
|
return {
|
|
mount: (target: unknown) => {
|
|
trace.calls.push("mount");
|
|
trace.target = target;
|
|
if (over.mountThrows === true) throw new Error("no such node");
|
|
},
|
|
destroy: () => {
|
|
trace.calls.push("destroy");
|
|
trace.destroys += 1;
|
|
if (over.destroyThrows === true) throw new Error("already gone");
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const load: import("../src/stripe.js").LoadStripeJs = async (src) => {
|
|
loaded.push(src);
|
|
return (key: string) => {
|
|
trace.calls.push("Stripe");
|
|
trace.key = key;
|
|
return {
|
|
elements: (options: { clientSecret: string; appearance?: { theme?: string } }) => {
|
|
trace.calls.push("elements");
|
|
trace.secret = options.clientSecret;
|
|
trace.appearance = options.appearance?.theme ?? "";
|
|
if (over.elementsFails === true) throw new Error("no such intent");
|
|
return elements;
|
|
},
|
|
confirmPayment: async (options: { confirmParams: { return_url: string } }) => {
|
|
trace.calls.push("confirmPayment");
|
|
trace.returnUrl = options.confirmParams.return_url;
|
|
if (over.confirmRejects === true) throw new Error("network");
|
|
return over.confirmResult ?? { paymentIntent: { status: "succeeded" } };
|
|
},
|
|
};
|
|
};
|
|
};
|
|
return { load, trace, loaded };
|
|
}
|
|
|
|
function loadPlan(): import("../src/stripe.js").LoadPlan {
|
|
const plan = stripe.cardPlan(PUBLISHABLE_KEY, false);
|
|
assert.equal(plan.kind, "load");
|
|
return plan as import("../src/stripe.js").LoadPlan;
|
|
}
|
|
|
|
cardTest("stripe: mounting follows Stripe's script rule — init the SDK, create the element, mount it", async () => {
|
|
const { load, trace, loaded } = fakeStripe();
|
|
const target = { the: "mount point" };
|
|
const result = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
|
|
assert.equal(result.kind, "mounted");
|
|
assert.deepEqual(trace.calls, ["Stripe", "elements", "create", "mount"]);
|
|
assert.deepEqual(loaded, [stripe.STRIPE_JS_URL], "loaded once, from js.stripe.com");
|
|
assert.equal(trace.key, PUBLISHABLE_KEY);
|
|
assert.equal(trace.secret, CLIENT_SECRET, "the client_secret, and nothing else about the order");
|
|
assert.equal(trace.target, target, "the Element goes into the node it was handed");
|
|
assert.equal(trace.createArg, "payment", "the Payment Element, with no field configuration of ours");
|
|
});
|
|
|
|
cardTest("stripe: the appearance follows the site theme, resolving system by the OS", () => {
|
|
for (const [label, t, osDark, ink] of [
|
|
["forced light ignores a dark OS", "light", true, "#0D1B3A"],
|
|
["forced dark ignores a light OS", "dark", false, "#EAF2FF"],
|
|
["system follows a dark OS", "system", true, "#EAF2FF"],
|
|
["system follows a light OS", "system", false, "#0D1B3A"],
|
|
] as const) {
|
|
const a = stripe.appearanceFor(t, osDark);
|
|
assert.equal(a.theme, "flat", label);
|
|
assert.equal(a.variables?.colorText, ink, label);
|
|
}
|
|
});
|
|
|
|
cardTest("stripe: the chosen appearance is handed to the SDK, so the Element matches the page", async () => {
|
|
const { load, trace } = fakeStripe();
|
|
await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: { theme: "night" }, returnUrl: RETURN_URL, loadStripe: load });
|
|
assert.equal(trace.appearance, "night", "the dark theme reached elements()");
|
|
});
|
|
|
|
cardTest("stripe: a mounted form can be torn down, once, and a throwing teardown is survivable", async () => {
|
|
const { load, trace } = fakeStripe();
|
|
const mounted = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
|
|
if (mounted.kind !== "mounted") throw new Error("expected a mounted form");
|
|
assert.equal(trace.destroys, 0, "mounting tears nothing down");
|
|
mounted.destroy();
|
|
assert.equal(trace.destroys, 1, "Stripe's own teardown is what takes the iframes with it");
|
|
mounted.destroy();
|
|
assert.equal(trace.destroys, 1, "idempotent: a second release destroys nothing twice");
|
|
|
|
const thrower = fakeStripe({ destroyThrows: true });
|
|
const second = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: thrower.load });
|
|
if (second.kind !== "mounted") throw new Error("expected a mounted form");
|
|
assert.doesNotThrow(() => { second.destroy(); });
|
|
assert.equal(thrower.trace.destroys, 1);
|
|
});
|
|
|
|
cardTest("stripe: a script that does not load is a failure, and nothing is mounted", async () => {
|
|
const { trace } = fakeStripe();
|
|
const result = await stripe.mountCard({
|
|
plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL,
|
|
loadStripe: async () => { throw new Error("blocked"); },
|
|
});
|
|
assert.equal(result.kind, "failed");
|
|
assert.equal(result.kind === "failed" ? result.reason : "", "script");
|
|
assert.deepEqual(trace.calls, [], "nothing of Stripe's was touched");
|
|
});
|
|
|
|
cardTest("stripe: an SDK that refuses the client secret is its own failure", async () => {
|
|
const { load } = fakeStripe({ elementsFails: true });
|
|
const result = await stripe.mountCard({ plan: loadPlan(), clientSecret: "cs_gone", target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
|
|
assert.equal(result.kind, "failed");
|
|
assert.equal(result.kind === "failed" ? result.reason : "", "sdk");
|
|
});
|
|
|
|
cardTest("stripe: a mount that throws leaves no half-usable form", async () => {
|
|
const { load } = fakeStripe({ mountThrows: true });
|
|
const result = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
|
|
assert.equal(result.kind, "failed");
|
|
assert.equal(result.kind === "failed" ? result.reason : "", "sdk");
|
|
});
|
|
|
|
async function confirmWith(over: Parameters<typeof fakeStripe>[0]): Promise<{
|
|
outcome: import("../src/stripe.js").ConfirmOutcome; trace: Trace;
|
|
}> {
|
|
const { load, trace } = fakeStripe(over);
|
|
const mounted = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
|
|
if (mounted.kind !== "mounted") throw new Error(`expected a mounted form, got ${mounted.kind}`);
|
|
return { outcome: await mounted.confirm(), trace };
|
|
}
|
|
|
|
cardTest("stripe: confirming is confirmPayment(), and a succeeded intent is `submitted` only", async () => {
|
|
const { outcome, trace } = await confirmWith({});
|
|
assert.deepEqual(outcome, { kind: "submitted" });
|
|
assert.equal(trace.calls.at(-1), "confirmPayment");
|
|
assert.equal(trace.returnUrl, RETURN_URL, "confirm is handed the return URL Stripe requires");
|
|
assert.ok(!("paid" in outcome) && !("settledAt" in outcome));
|
|
});
|
|
|
|
cardTest("stripe: a processing intent is also `submitted` — the poller settles it", async () => {
|
|
const { outcome } = await confirmWith({ confirmResult: { paymentIntent: { status: "processing" } } });
|
|
assert.deepEqual(outcome, { kind: "submitted" });
|
|
});
|
|
|
|
cardTest("stripe: a refusal is the reason Stripe gave, and the form stays", async () => {
|
|
const { outcome } = await confirmWith({ confirmResult: { error: { message: "Your card was declined." } } });
|
|
assert.deepEqual(outcome, { kind: "error", message: "Your card was declined." });
|
|
});
|
|
|
|
cardTest("stripe: an error with no message of its own still says something usable", async () => {
|
|
const { outcome } = await confirmWith({ confirmResult: { error: {} } });
|
|
assert.deepEqual(outcome, { kind: "error", message: stripe.CONFIRM_FAILED });
|
|
});
|
|
|
|
cardTest("stripe: an intent left in a non-terminal status is an error, not a submission", async () => {
|
|
const { outcome } = await confirmWith({ confirmResult: { paymentIntent: { status: "requires_payment_method" } } });
|
|
assert.deepEqual(outcome, { kind: "error", message: stripe.CONFIRM_FAILED });
|
|
});
|
|
|
|
cardTest("stripe: a confirm that THREW is an error, and never a submission", async () => {
|
|
const { outcome } = await confirmWith({ confirmRejects: true });
|
|
assert.equal(outcome.kind, "error", "a rejected confirm must not move the page to the confirming screen");
|
|
});
|
|
|
|
cardTest("screens: the card fields are disabled until the Element is actually mounted", () => {
|
|
let paid = 0;
|
|
const mount = screens.cardMount();
|
|
const fields = screens.cardFields({ mount, total: "$420.00", onPay: () => { paid += 1; } });
|
|
const node = render(fields.node);
|
|
assert.equal(node.all("div.card-mount")[0], render(mount), "the Element goes into the node handed in");
|
|
const pay = node.all("button.primary")[0]!;
|
|
assert.ok(pay.textContent.includes("$420.00"));
|
|
assert.ok(pay.hasAttribute("disabled"), "Stripe.js has not run yet");
|
|
assert.ok(node.textContent.includes(screens.CARD_LOADING));
|
|
pay.click();
|
|
assert.equal(paid, 0, "a disabled control does nothing");
|
|
fields.enable();
|
|
assert.ok(!pay.hasAttribute("disabled"));
|
|
pay.click();
|
|
assert.equal(paid, 1);
|
|
fields.busy(true);
|
|
pay.click();
|
|
assert.equal(paid, 1, "one press, one attempt — there is no idempotency key");
|
|
fields.busy(false);
|
|
fields.error("Your card was declined.");
|
|
assert.ok(node.textContent.includes("Your card was declined."));
|
|
});
|
|
|
|
cardTest("screens: the card form renders the body it is given, and no second mount point", () => {
|
|
const marker = new StubElement("p");
|
|
marker.textContent = "custom body marker";
|
|
const withBody = render(screens.cardForm({
|
|
order, invoice: cardInvoice, resumed: false,
|
|
body: marker as unknown as HTMLElement,
|
|
}));
|
|
assert.ok(withBody.textContent.includes("custom body marker"), "the body handed in is rendered");
|
|
assert.ok(withBody.textContent.includes("Legend"));
|
|
assert.ok(withBody.textContent.includes("$420.00"));
|
|
assert.ok(withBody.textContent.includes("inv_card"), "the reference is on every screen that may need support");
|
|
assert.equal(withBody.all("div.card-mount").length, 0, "a supplied body brings no mount point of its own");
|
|
assert.ok(!withBody.textContent.includes(CLIENT_SECRET), "the client secret is never rendered");
|
|
assertNoCode(withBody, "cardForm/body");
|
|
assert.equal(withBody.all("svg.qr").length, 0, "the store rules: no QR on an unpaid screen");
|
|
|
|
const bare = render(screens.cardForm({ order, invoice: cardInvoice, resumed: false }));
|
|
assert.equal(bare.all("div.card-mount").length, 1, "the default body is the mount point");
|
|
});
|
|
|
|
cardTest("screens: a card form that could not load is a screen, not a blank panel", () => {
|
|
let retried = 0;
|
|
let fresh = 0;
|
|
for (const [reason, expected] of [["offline", "You are offline"], ["script", "served by Stripe"], ["sdk", "served by Stripe"]] as const) {
|
|
const p = render(screens.cardUnavailable({
|
|
order, reason, onRetry: () => { retried += 1; }, onNewInvoice: () => { fresh += 1; },
|
|
}));
|
|
assert.equal(p.all("h1")[0]!.textContent, "The card form did not load");
|
|
assert.ok(p.textContent.includes(expected), `${reason}: ${p.textContent}`);
|
|
assert.ok(p.textContent.includes("Nothing was charged"));
|
|
assert.ok(p.textContent.includes("still waiting to be paid"));
|
|
assert.ok(p.textContent.includes("inv_card"));
|
|
assert.equal(p.all("div.card-mount").length, 0, "no mount point on a screen with nothing to mount");
|
|
assertNoCode(p, `cardUnavailable/${reason}`);
|
|
}
|
|
const p = render(screens.cardUnavailable({ order, reason: "script", onRetry: () => { retried += 1; }, onNewInvoice: () => { fresh += 1; } }));
|
|
p.all("button.primary")[0]!.click();
|
|
p.all("button.secondary")[0]!.click();
|
|
assert.equal(retried, 1, "[ Try again ] retries");
|
|
assert.equal(fresh, 1, "and the buyer is never stranded");
|
|
});
|
|
|
|
const screen = (): StubElement => inViewOf(app);
|
|
const heading = (): string => headingOf(screen());
|
|
|
|
function expireStoredOrder(orderId: string): void {
|
|
const orders = JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, unknown>>;
|
|
orders.find((o) => o.orderId === orderId)!.status = "expired";
|
|
storage.setItem("sb.orders.v1", JSON.stringify(orders));
|
|
}
|
|
|
|
function scripts(): StubElement[] {
|
|
return page.document.head.children.filter((c): c is StubElement => c instanceof StubElement);
|
|
}
|
|
function stripeTags(): StubElement[] {
|
|
return scripts().filter((s) => (s.getAttribute("src") ?? "").includes("js.stripe.com"));
|
|
}
|
|
|
|
cardTest("main: nothing of Stripe's is fetched at page load", () => {
|
|
assert.equal(heading(), "Support SimpleX", "the boot is the landing screen");
|
|
assert.equal(scripts().length, 0, "the shell adds no script of its own");
|
|
assert.equal(stripeTags().length, 0, "Stripe.js is loaded on the card path and nowhere else");
|
|
assert.ok(!fetches.some((f) => f.url.includes("stripe")), "and nothing requested it another way");
|
|
});
|
|
|
|
function walkToCard(): void {
|
|
const primary = (): StubElement => primaryOf(screen())!;
|
|
if (heading() === "Support SimpleX") primary().click();
|
|
if (heading() === "Choose your badge") {
|
|
screen().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!.click();
|
|
primary().click();
|
|
}
|
|
if (heading() === "How long?") {
|
|
screen().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click();
|
|
primary().click();
|
|
}
|
|
assert.equal(heading(), "Check your order");
|
|
screen().all("button.choice").find((c) => c.textContent.startsWith("Card"))!.click();
|
|
}
|
|
|
|
function cardCreated(invoiceId: string): void {
|
|
page.respondWith({
|
|
status: 200,
|
|
body: {
|
|
invoiceId, badgeType: "legend", months: 12,
|
|
amount: 42000, currency: "usd", expiresAt: "2126-08-28T13:00:00Z",
|
|
clientSecret: CLIENT_SECRET,
|
|
},
|
|
});
|
|
}
|
|
|
|
cardTest("main: with NO key the card path is unavailable, and still loads nothing", async () => {
|
|
walkToCard();
|
|
cardCreated("inv_card_1");
|
|
screen().all("button.primary")[0]!.click();
|
|
await until(() => location.search === "?order=inv_card_1", "the card invoice");
|
|
await settle();
|
|
|
|
assert.equal(heading(), "The card form did not load");
|
|
assert.ok(screen().textContent.includes("Card payment is not available on this page."),
|
|
"an unconfigured page names the card lane as unavailable, and offers no form");
|
|
assert.equal(screen().all("div.card-mount").length, 0);
|
|
assert.equal(stripeTags().length, 0, "no key, no script — the gate is before the load");
|
|
assert.ok(!screen().textContent.includes(CLIENT_SECRET), "the client secret is never on screen");
|
|
const stored = JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, string>>;
|
|
assert.ok(stored[0]!.code!.startsWith("SB-"));
|
|
assert.ok(!screen().serialize().includes(stored[0]!.code!), "and never on an unpaid screen");
|
|
assert.equal(storage.getItem("sb.orders.v1")!.includes(CLIENT_SECRET), false,
|
|
"the store rules: clientSecret is never written to rest");
|
|
});
|
|
|
|
cardTest("main: the order summary withholds Pay while a card payment awaits confirmation", async () => {
|
|
const orders = JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, unknown>>;
|
|
orders.find((o) => o.orderId === "inv_card_1")!.submitted = true;
|
|
storage.setItem("sb.orders.v1", JSON.stringify(orders));
|
|
|
|
history.back();
|
|
await settle();
|
|
assert.equal(heading(), "How long?", "Back lands on the wizard");
|
|
primaryOf(screen())!.click();
|
|
assert.equal(heading(), "Check your order");
|
|
|
|
assert.ok(screen().textContent.includes(screens.AWAITING_CARD_TITLE), screen().textContent);
|
|
assert.equal(screen().all("button.primary").length, 0, "and no Pay button at all");
|
|
assert.ok(!screen().textContent.includes("Pay $"), "not even a disabled one to press");
|
|
const link = screen().all("a.link")[0]!;
|
|
assert.equal(link.getAttribute("href"), "?order=inv_card_1", "the order is linked, so nobody is stranded");
|
|
assert.ok(screen().textContent.includes("When its invoice expires, a new one can be started there."),
|
|
"and the way out is named: the closed-window screen offers a new invoice once this one expires");
|
|
|
|
const before = fetches.length;
|
|
await settle();
|
|
assert.equal(fetches.slice(before).filter((f) => f.url === "/api/invoice").length, 0);
|
|
});
|
|
|
|
cardTest("main: Back from a payment screen to the order summary, and Pay still works", async () => {
|
|
expireStoredOrder("inv_card_1");
|
|
history.back();
|
|
await settle();
|
|
assert.equal(heading(), "How long?", "Back lands on the wizard, not on the replaced #/checkout");
|
|
|
|
screen().all("button.choice").find((c) => c.textContent.startsWith("3 months"))!.click();
|
|
primaryOf(screen())!.click();
|
|
assert.equal(heading(), "Check your order");
|
|
assert.ok(screen().textContent.includes("$140.00"), `3 legend months are $140: ${screen().textContent}`);
|
|
screen().all("button.choice").find((c) => c.textContent.startsWith("Card"))!.click();
|
|
|
|
configureKey(PUBLISHABLE_KEY);
|
|
const before = fetches.length;
|
|
page.respondWith({
|
|
status: 200,
|
|
body: {
|
|
invoiceId: "inv_card_2", badgeType: "legend", months: 3,
|
|
amount: 14000, currency: "usd", expiresAt: "2126-08-28T13:00:00Z",
|
|
clientSecret: CLIENT_SECRET,
|
|
},
|
|
});
|
|
screen().all("button.primary")[0]!.click();
|
|
await until(() => location.search === "?order=inv_card_2", "the second card invoice");
|
|
await settle();
|
|
|
|
const post = fetches.slice(before).find((f) => f.url === "/api/invoice");
|
|
assert.ok(post, "Pay must actually submit — a dead primary button is worse than an error");
|
|
const body = JSON.parse(String(post.init!.body)) as Record<string, string>;
|
|
assert.equal(body.priceId, "price_legend", "the level the panel was drawn with");
|
|
assert.equal(body.offerId, "offer_3m", "and the duration the buyer just chose, not the old order's");
|
|
assert.equal(body.method, "card");
|
|
});
|
|
|
|
cardTest("main: with a key configured the card form loads Stripe.js and awaits its Element", () => {
|
|
assert.equal(heading(), "Pay by card");
|
|
assert.equal(screen().all("div.card-mount").length, 1, "the Payment Element mounts here");
|
|
assert.equal(stripeTags().length, 1, "exactly one script tag, and only now");
|
|
assert.equal(stripeTags()[0]!.getAttribute("src"), stripe.STRIPE_JS_URL);
|
|
const pay = screen().all("button.primary")[0]!;
|
|
assert.ok(pay.hasAttribute("disabled"), "nothing is payable until the Element is mounted");
|
|
});
|
|
|
|
cardTest("main: a Stripe.js that fails to load lands on the failure screen, not a blank panel", async () => {
|
|
stripeTags()[0]!.dispatch("error");
|
|
await settle();
|
|
|
|
assert.equal(heading(), "The card form did not load");
|
|
assert.ok(screen().textContent.includes("Nothing was charged"));
|
|
assert.ok(screen().textContent.includes("inv_card"), "with the reference, so support is reachable");
|
|
assert.equal(screen().all("div.card-mount").length, 0, "and no empty box where the fields were");
|
|
});
|
|
|
|
const elements: Array<{ node: unknown; destroyed: boolean }> = [];
|
|
const confirms: number[] = [];
|
|
let lastReturnUrl = "";
|
|
let heldConfirm: { promise: Promise<void>; release: () => void } | null = null;
|
|
function holdNextConfirm(): void {
|
|
let release = (): void => {};
|
|
const promise = new Promise<void>((r) => { release = r; });
|
|
heldConfirm = { promise, release };
|
|
}
|
|
|
|
(globalThis as unknown as { window: Record<string, unknown> }).window.Stripe = (key: string) => {
|
|
assert.equal(key, PUBLISHABLE_KEY, "the page's own configured key, and no other");
|
|
return {
|
|
elements: (options: { clientSecret: string }) => {
|
|
assert.equal(options.clientSecret, CLIENT_SECRET);
|
|
return {
|
|
create: (kind: string) => {
|
|
assert.equal(kind, "payment", "the Payment Element, not a bare card field");
|
|
const element = { node: null as unknown, destroyed: false };
|
|
elements.push(element);
|
|
return {
|
|
mount: (node: unknown) => { element.node = node; },
|
|
destroy: () => { element.destroyed = true; },
|
|
};
|
|
},
|
|
};
|
|
},
|
|
confirmPayment: async (options: { confirmParams: { return_url: string } }) => {
|
|
confirms.push(1);
|
|
lastReturnUrl = options.confirmParams.return_url;
|
|
if (heldConfirm !== null) await heldConfirm.promise;
|
|
return { paymentIntent: { status: "succeeded" } };
|
|
},
|
|
};
|
|
};
|
|
|
|
cardTest("main: [ Try again ] asks for the script again, and a load mounts the Element", async () => {
|
|
screen().all("button.primary")[0]!.click(); // [ Try again ]
|
|
await settle();
|
|
assert.equal(heading(), "Pay by card");
|
|
assert.equal(stripeTags().length, 1, "the failed load is retried with a fresh tag, and only that");
|
|
|
|
stripeTags()[0]!.dispatch("load");
|
|
await settle();
|
|
assert.equal(elements.length, 1, "the Element was mounted");
|
|
assert.equal(elements[0]!.node, screen().all("div.card-mount")[0], "into the node on screen");
|
|
const pay = screen().all("button.primary")[0]!;
|
|
assert.ok(!pay.hasAttribute("disabled"), "and only now is the order payable");
|
|
assert.equal(confirms.length, 0, "mounting confirms nothing");
|
|
});
|
|
|
|
cardTest("main: a remount DESTROYS the Element it replaces, and loads no second script", async () => {
|
|
page.setOffline(true);
|
|
await settle();
|
|
try {
|
|
assert.equal(heading(), "The card form did not load");
|
|
assert.ok(screen().textContent.includes("You are offline"), screen().textContent);
|
|
assert.equal(elements.length, 1, "and nothing new was mounted");
|
|
assert.equal(elements[0]!.destroyed, true, "the Element that was on screen is gone");
|
|
} finally {
|
|
// Restored even if an assertion above fails, or the offline backoff on real timers would hang this process.
|
|
page.setOffline(false);
|
|
await settle();
|
|
}
|
|
assert.equal(heading(), "Pay by card");
|
|
assert.equal(elements.length, 2, "coming back mounts a fresh Element");
|
|
assert.equal(elements[1]!.destroyed, false, "which is the live one");
|
|
assert.equal(elements[1]!.node, screen().all("div.card-mount")[0]);
|
|
assert.equal(stripeTags().length, 1, "one load per page: the global is reused, not re-fetched");
|
|
assert.ok(!screen().all("button.primary")[0]!.hasAttribute("disabled"), "and it is payable again");
|
|
});
|
|
|
|
cardTest("main: a connectivity flap must not remount the form under an in-flight confirm", async () => {
|
|
holdNextConfirm();
|
|
const mounted = elements.length;
|
|
const node = screen().all("div.card-mount")[0];
|
|
screen().all("button.primary")[0]!.click(); // Pay
|
|
await settle();
|
|
assert.equal(confirms.length, 1, "the press issued one confirm");
|
|
assert.ok(screen().all("button.primary")[0]!.hasAttribute("disabled"), "and the button went busy");
|
|
|
|
page.setOffline(true);
|
|
await settle();
|
|
page.setOffline(false);
|
|
await settle();
|
|
|
|
assert.equal(heading(), "Pay by card", "the form the confirm belongs to is still the screen");
|
|
assert.equal(elements.length, mounted, "nothing was remounted under the in-flight confirm");
|
|
assert.equal(elements[mounted - 1]!.destroyed, false, "and the live Element was not torn down");
|
|
assert.equal(screen().all("div.card-mount")[0], node, "it is the same form, with whatever was typed into it");
|
|
assert.ok(screen().all("button.primary")[0]!.hasAttribute("disabled"),
|
|
"the busy state survives the repaint — a re-enabled button is a second charge");
|
|
assert.equal(confirms.length, 1, "and still exactly one confirm was issued");
|
|
|
|
heldConfirm!.release();
|
|
heldConfirm = null;
|
|
await until(() => heading() === "Payment received", "the confirming screen once the confirm resolves");
|
|
assert.equal(confirms.length, 1);
|
|
});
|
|
|
|
cardTest("main: a real confirm ALSO lands on the confirming screen, and the confirming screen alone", async () => {
|
|
assert.equal(heading(), "Payment received");
|
|
assert.ok(screen().textContent.includes("Waiting for the card network to confirm."),
|
|
"the confirming screen waits: a confirm is a hint, the provider is what settles it");
|
|
assert.equal(confirms.length, 1, "one press, one confirm");
|
|
assert.ok(fetches.some((f) => f.url.startsWith("/api/invoice/inv_card_2")), "the waiting loop is polling");
|
|
assert.ok(lastReturnUrl.length > 0 && !lastReturnUrl.includes("inv_card_2"),
|
|
`the return URL leaks no order id: ${lastReturnUrl}`);
|
|
assert.ok(elements.every((e) => e.destroyed), "and the form it left behind took its Element with it");
|
|
assert.equal(screen().all("button").filter((b) => b.textContent === "Buy a new code").length, 0);
|
|
assert.ok(!screen().textContent.includes("Here is your code"));
|
|
const stored = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, string>>)
|
|
.find((o) => o.orderId === "inv_card_2")!;
|
|
assert.equal(stored.status, "open", "nothing about the order changed: the provider decides");
|
|
assert.ok(!screen().serialize().includes(stored.code!));
|
|
assert.equal(stored.submitted, true);
|
|
assert.equal(storage.getItem("sb.session.v1"), null, "and not in the session");
|
|
});
|
|
|
|
cardTest("main: the code appears only when the SERVER says paid", async () => {
|
|
const stored = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, string>>)
|
|
.find((o) => o.orderId === "inv_card_2")!;
|
|
page.respondWith({
|
|
status: 200,
|
|
body: { status: "paid", badgeType: "legend", months: 3, settledAt: "2026-08-28T11:20:00Z" },
|
|
});
|
|
page.fire("popstate");
|
|
await until(() => heading().startsWith("Paid"), "codeIssued");
|
|
assert.ok(screen().serialize().includes(stored.code!), "the code screen is the one screen that shows it");
|
|
});
|