mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
<!-- 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 -->
413 lines
14 KiB
TypeScript
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);
|
|
});
|