stack/apps/e2e/tests/js/payments.test.ts
BilalG1 4e45aed530
fix product route access (#1134)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Security**
* Added client-side access checks on payments endpoints and expanded
customer-type handling (including a new "custom" type).

* **SDK / Client**
* Client interface methods now accept explicit request types
(client/server/admin) to route requests appropriately.

* **Server**
* New server-side product listing to support server requests and
caching.

* **Tests**
* E2E tests updated to use a fast sign-up flow and pass authentication
tokens for authorized requests.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-27 18:30:14 +00:00

413 lines
14 KiB
TypeScript

import { it } from "../helpers";
import { withPortPrefix } from "../helpers/ports";
import { createApp } from "./js-helpers";
it("createCheckoutUrl supports optional returnUrl and embeds it", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({ config: {} });
const project = await adminApp.getProject();
await adminApp.setupPayments();
await project.updateConfig({
"payments.offers.test-offer": {
displayName: "Test Offer",
customerType: "user",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({ email: "checkout-return@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "checkout-return@test.com", password: "password" });
const user = await clientApp.getUser();
if (!user) throw new Error("User not found");
const url = await user.createCheckoutUrl({ productId: "test-offer", returnUrl: "http://stack-test.localhost/after" });
expect(url).toMatch(new RegExp(`^https?:\\/\\/localhost:${withPortPrefix("01")}\\/purchase\\/[a-z0-9-_]+\\?return_url=`));
const urlObj = new URL(url);
expect(urlObj.searchParams.get("return_url")).toBe("http://stack-test.localhost/after");
}, { timeout: 60_000 });
it("returns default item quantity for a team", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item",
customerType: "team",
},
});
await clientApp.signUpWithCredential({
email: "test@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "test@test.com",
password: "password",
});
const user = await clientApp.getUser();
expect(user).not.toBeNull();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "Test Team" });
const item = await team.getItem(itemId);
expect(item.displayName).toBe("Test Item");
expect(item.quantity).toBe(0);
expect(item.nonNegativeQuantity).toBe(0);
}, {
timeout: 40_000,
});
it("root-level getItem works for user and team", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({
config: { clientTeamCreationEnabled: true },
});
const project = await adminApp.getProject();
const itemId = "root_level_item";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Root Level Item",
customerType: "team",
},
});
await clientApp.signUpWithCredential({ email: "rl@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "rl@test.com", password: "password" });
const user = await clientApp.getUser();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "RL Team" });
const teamItem = await clientApp.getItem({ itemId, teamId: team.id });
expect(teamItem.quantity).toBe(0);
const userItemId = "root_level_user_item";
await project.updateConfig({
[`payments.items.${userItemId}`]: {
displayName: "Root Level User Item",
customerType: "user",
},
});
const userItem = await clientApp.getItem({ itemId: userItemId, userId: user.id });
expect(userItem.quantity).toBe(0);
}, { timeout: 60_000 });
it("customCustomerId is supported via root-level getItem and admin quantity change", async ({ expect }) => {
const { serverApp, clientApp, adminApp } = await createApp({
config: {},
});
const project = await adminApp.getProject();
const itemId = "custom_item_rl";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Custom RL Item",
customerType: "custom",
},
});
const customCustomerId = "custom-abc";
const before = await serverApp.getItem({ itemId, customCustomerId });
expect(before.quantity).toBe(0);
await adminApp.createItemQuantityChange({ customCustomerId, itemId, quantity: 5 });
const after = await serverApp.getItem({ itemId, customCustomerId });
expect(after.quantity).toBe(5);
}, { timeout: 60_000 });
it("admin can increase team item quantity and client sees updated value", async ({ expect }) => {
const { clientApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item_inc";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item Inc",
customerType: "team",
},
});
await clientApp.signUpWithCredential({
email: "inc@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({ email: "inc@test.com", password: "password" });
const user = await clientApp.getUser();
expect(user).not.toBeNull();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "Team Inc" });
const before = await team.getItem(itemId);
expect(before.quantity).toBe(0);
// Increase by 3 via admin API
await adminApp.createItemQuantityChange({ teamId: team.id, itemId, quantity: 3 });
const after = await team.getItem(itemId);
expect(after.quantity).toBe(3);
expect(after.nonNegativeQuantity).toBe(3);
}, { timeout: 40_000 });
it("cannot decrease team item quantity below zero", async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item_dec";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item Dec",
customerType: "team",
},
});
await clientApp.signUpWithCredential({
email: "dec@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({ email: "dec@test.com", password: "password" });
const user = await clientApp.getUser();
expect(user).not.toBeNull();
if (!user) throw new Error("User not found");
const team = await user.createTeam({ displayName: "Team Dec" });
const current = await team.getItem(itemId);
expect(current.quantity).toBe(0);
// Try to decrease by 1 (should fail with KnownErrors.ItemQuantityInsufficientAmount)
const item = await serverApp.getItem({ teamId: team.id, itemId });
const success = await item.tryDecreaseQuantity(1);
expect(success).toBe(false);
const still = await team.getItem(itemId);
expect(still.quantity).toBe(0);
}, { timeout: 40_000 });
it("client can cancel their own subscription", async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({ config: {} });
const project = await adminApp.getProject();
await adminApp.setupPayments();
await project.updateConfig({
"payments.offers.pro-sub": {
displayName: "Pro Subscription",
customerType: "user",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({ email: "cancel-sub@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "cancel-sub@test.com", password: "password" });
const user = await clientApp.getUser({ or: "throw" });
await serverApp.grantProduct({ userId: user.id, productId: "pro-sub" });
const before = await user.listProducts();
expect(before.some((p) => p.id === "pro-sub")).toBe(true);
await clientApp.cancelSubscription({ productId: "pro-sub" });
const after = await user.listProducts();
expect(after.some((p) => p.id === "pro-sub")).toBe(false);
}, { timeout: 60_000 });
it("team admin can cancel a team's subscription", async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({ config: { clientTeamCreationEnabled: true } });
const project = await adminApp.getProject();
await adminApp.setupPayments();
await project.updateConfig({
"payments.offers.team-sub": {
displayName: "Team Subscription",
customerType: "team",
serverOnly: false,
stackable: false,
prices: { monthly: { USD: "1000", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({ email: "cancel-team-sub@test.com", password: "password", verificationCallbackUrl: "http://localhost:3000" });
await clientApp.signInWithCredential({ email: "cancel-team-sub@test.com", password: "password" });
const user = await clientApp.getUser({ or: "throw" });
const team = await user.createTeam({ displayName: "Cancel Team" });
await serverApp.grantProduct({ teamId: team.id, productId: "team-sub" });
const before = await team.listProducts();
expect(before.some((p) => p.id === "team-sub")).toBe(true);
await clientApp.cancelSubscription({ productId: "team-sub", teamId: team.id });
const after = await team.listProducts();
expect(after.some((p) => p.id === "team-sub")).toBe(false);
}, { timeout: 60_000 });
it("can create item quantity change from server app", { timeout: 40_000 }, async ({ expect }) => {
const { serverApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
const itemId = "test_item_change";
await project.updateConfig({
[`payments.items.${itemId}`]: {
displayName: "Test Item Change",
customerType: "user",
},
});
const user = await serverApp.createUser({ primaryEmail: "test@test.com" });
const item = await user.getItem(itemId);
expect(item.quantity).toBe(0);
expect(item.nonNegativeQuantity).toBe(0);
expect(item.displayName).toBe("Test Item Change");
await item.increaseQuantity(2);
const newItem = await user.getItem(itemId);
expect(newItem.quantity).toBe(2);
await newItem.decreaseQuantity(1);
const newItem2 = await user.getItem(itemId);
expect(newItem2.quantity).toBe(1);
const resultSuccess = await newItem2.tryDecreaseQuantity(1);
expect(resultSuccess).toBe(true);
const newItem3 = await user.getItem(itemId);
expect(newItem3.quantity).toBe(0);
const resultFailure = await newItem3.tryDecreaseQuantity(1);
expect(resultFailure).toBe(false);
const newItem4 = await user.getItem(itemId);
expect(newItem4.quantity).toBe(0);
});
it("supports granting and listing customer products", { timeout: 60_000 }, async ({ expect }) => {
const { clientApp, serverApp, adminApp } = await createApp({
config: {
clientTeamCreationEnabled: true,
},
});
const project = await adminApp.getProject();
await project.updateConfig({
"payments.offers.test-offer": {
displayName: "Config Offer",
customerType: "user",
serverOnly: false,
stackable: true,
prices: { monthly: { USD: "1500", interval: [1, "month"] } },
includedItems: {},
},
});
await clientApp.signUpWithCredential({
email: "products@example.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "products@example.com",
password: "password",
});
const user = await clientApp.getUser();
if (!user) throw new Error("User not found");
await serverApp.grantProduct({ userId: user.id, productId: "test-offer", quantity: 2 });
const inlineUserProduct = {
display_name: "Inline User Offer",
customer_type: "user",
server_only: false,
stackable: true,
prices: {
onetime: { USD: "500" },
},
included_items: {},
} as const;
await serverApp.grantProduct({ userId: user.id, product: inlineUserProduct });
const allUserProducts = await serverApp.listProducts({ userId: user.id });
expect(allUserProducts).toHaveLength(2);
expect(allUserProducts.nextCursor).toBeNull();
const configGrant = allUserProducts.find((product) => product.displayName === "Config Offer");
expect(configGrant?.quantity).toBe(2);
const inlineGrant = allUserProducts.find((product) => product.displayName === inlineUserProduct.display_name);
expect(inlineGrant?.quantity).toBe(1);
const paginatedUserProducts = await serverApp.listProducts({ userId: user.id, limit: 1 });
expect(paginatedUserProducts).toHaveLength(1);
expect(paginatedUserProducts.nextCursor).not.toBeNull();
const nextPage = await serverApp.listProducts({ userId: user.id, cursor: paginatedUserProducts.nextCursor!, limit: 1 });
expect(nextPage).toHaveLength(1);
expect(nextPage.nextCursor).toBeNull();
const userProductsFromCustomer = await serverApp.listProducts({ userId: user.id });
expect(userProductsFromCustomer).toHaveLength(2);
const team = await user.createTeam({ displayName: "Products Team" });
const inlineTeamProduct = {
display_name: "Team Inline Offer",
customer_type: "team",
server_only: false,
stackable: true,
prices: {
quarterly: { USD: "2500" },
},
included_items: {},
} as const;
await serverApp.grantProduct({ teamId: team.id, product: inlineTeamProduct, quantity: 1 });
const teamProducts = await serverApp.listProducts({ teamId: team.id });
expect(teamProducts).toHaveLength(1);
expect(teamProducts[0].quantity).toBe(1);
expect(teamProducts[0].displayName).toBe(inlineTeamProduct.display_name);
const teamProductsFromCustomer = await serverApp.listProducts({ teamId: team.id });
expect(teamProductsFromCustomer).toHaveLength(1);
expect(teamProductsFromCustomer[0].displayName).toBe(inlineTeamProduct.display_name);
const customCustomerId = "custom-products-id";
const inlineCustomProduct = {
display_name: "Custom Inline Offer",
customer_type: "custom",
server_only: false,
stackable: false,
prices: {
yearly: { USD: "10000" },
},
included_items: {},
} as const;
await serverApp.grantProduct({ customCustomerId, product: inlineCustomProduct, quantity: 1 });
const customProducts = await serverApp.listProducts({ customCustomerId });
expect(customProducts).toHaveLength(1);
expect(customProducts[0].quantity).toBe(1);
expect(customProducts[0].displayName).toBe(inlineCustomProduct.display_name);
});