-
-
Notifications
You must be signed in to change notification settings - Fork 11.5k
Added Stripe service and webhook e2e test #26516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,17 @@ | ||
| import baseDebug from '@tryghost/debug'; | ||
| import {Browser, BrowserContext, Page, TestInfo, test as base} from '@playwright/test'; | ||
| import {FakeStripeServer, StripeTestService, WebhookClient} from '@/helpers/services/stripe'; | ||
| import {GhostInstance, getEnvironmentManager} from '@/helpers/environment'; | ||
| import {SettingsService} from '@/helpers/services/settings/settings-service'; | ||
| import {faker} from '@faker-js/faker'; | ||
| import {loginToGetAuthenticatedSession} from '@/helpers/playwright/flows/sign-in'; | ||
| import {setupUser} from '@/helpers/utils'; | ||
|
|
||
| const debug = baseDebug('e2e:ghost-fixture'); | ||
| const STRIPE_FAKE_SERVER_PORT = 40000 + parseInt(process.env.TEST_PARALLEL_INDEX || '0', 10); | ||
| const STRIPE_SECRET_KEY = 'sk_test_e2eTestKey'; | ||
| const STRIPE_PUBLISHABLE_KEY = 'pk_test_e2eTestKey'; | ||
|
Comment on lines
+12
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gitleaks flags The key is intentionally fake, but its 🛡️ Suggested fix-const STRIPE_SECRET_KEY = 'sk_test_e2eTestKey';
-const STRIPE_PUBLISHABLE_KEY = 'pk_test_e2eTestKey';
+const STRIPE_SECRET_KEY = 'sk_test_e2eTestKey'; // gitleaks:allow - intentionally fake test key
+const STRIPE_PUBLISHABLE_KEY = 'pk_test_e2eTestKey'; // gitleaks:allow - intentionally fake test key🧰 Tools🪛 Gitleaks (8.30.0)[high] 12-12: Found a Stripe Access Token, posing a risk to payment processing services and sensitive financial data. (stripe-access-token) 🤖 Prompt for AI Agents |
||
|
|
||
| export interface User { | ||
| name: string; | ||
| email: string; | ||
|
|
@@ -23,7 +28,9 @@ export interface GhostInstanceFixture { | |
| ghostInstance: GhostInstance; | ||
| labs?: Record<string, boolean>; | ||
| config?: GhostConfig; | ||
| stripeConnected?: boolean; | ||
| stripeEnabled?: boolean; | ||
| stripeServer?: FakeStripeServer; | ||
| stripe?: StripeTestService; | ||
| ghostAccountOwner: User; | ||
| pageWithAuthenticatedUser: { | ||
| page: Page; | ||
|
|
@@ -59,19 +66,47 @@ async function setupNewAuthenticatedPage(browser: Browser, baseURL: string, ghos | |
| * - Build mode: Same isolation model, but Ghost runs from a prebuilt image | ||
| * | ||
| * Optionally allows setting labs flags via test.use({labs: {featureName: true}}) | ||
| * and Stripe connection via test.use({stripeConnected: true}) | ||
| * and Stripe connection via test.use({stripeEnabled: true}) | ||
| */ | ||
| export const test = base.extend<GhostInstanceFixture>({ | ||
| // Define options that can be set per test or describe block | ||
| config: [undefined, {option: true}], | ||
| labs: [undefined, {option: true}], | ||
| stripeConnected: [false, {option: true}], | ||
| stripeEnabled: [false, {option: true}], | ||
|
|
||
| stripeServer: async ({stripeEnabled}, use) => { | ||
| if (!stripeEnabled) { | ||
| await use(undefined); | ||
| return; | ||
| } | ||
|
|
||
| const server = new FakeStripeServer(STRIPE_FAKE_SERVER_PORT); | ||
| await server.start(); | ||
| debug('Fake Stripe server started on port', STRIPE_FAKE_SERVER_PORT); | ||
|
|
||
| await use(server); | ||
|
|
||
| await server.stop(); | ||
| debug('Fake Stripe server stopped'); | ||
| }, | ||
|
|
||
| // Each test gets its own Ghost instance with isolated database | ||
| ghostInstance: async ({config}, use, testInfo: TestInfo) => { | ||
| // Each test gets its own Ghost instance with isolated database. | ||
| ghostInstance: async ({config, stripeEnabled, stripeServer}, use, testInfo: TestInfo) => { | ||
| debug('Setting up Ghost instance for test:', testInfo.title); | ||
| const stripeConfig = stripeEnabled ? { | ||
| STRIPE_API_HOST: 'host.docker.internal', | ||
| STRIPE_API_PORT: String(STRIPE_FAKE_SERVER_PORT), | ||
| STRIPE_API_PROTOCOL: 'http' | ||
| } : {}; | ||
| const mergedConfig = {...(config || {}), ...stripeConfig}; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thought: Maybe
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stripe config is determined via the presence of the |
||
| const environmentManager = await getEnvironmentManager(); | ||
| const ghostInstance = await environmentManager.perTestSetup({config}); | ||
| const ghostInstance = await environmentManager.perTestSetup({ | ||
| config: mergedConfig, | ||
| stripe: stripeServer ? { | ||
| secretKey: STRIPE_SECRET_KEY, | ||
| publishableKey: STRIPE_PUBLISHABLE_KEY | ||
| } : undefined | ||
| }); | ||
|
|
||
| debug('Ghost instance ready for test:', { | ||
| testTitle: testInfo.title, | ||
|
|
@@ -84,6 +119,17 @@ export const test = base.extend<GhostInstanceFixture>({ | |
| debug('Teardown completed for test:', testInfo.title); | ||
| }, | ||
|
|
||
| stripe: async ({stripeEnabled, baseURL, stripeServer}, use) => { | ||
| if (!stripeEnabled || !baseURL || !stripeServer) { | ||
| await use(undefined); | ||
| return; | ||
| } | ||
|
|
||
| const webhookClient = new WebhookClient(baseURL); | ||
| const service = new StripeTestService(stripeServer, webhookClient); | ||
| await use(service); | ||
| }, | ||
|
|
||
| baseURL: async ({ghostInstance}, use) => { | ||
| await use(ghostInstance.baseUrl); | ||
| }, | ||
|
|
@@ -116,22 +162,17 @@ export const test = base.extend<GhostInstanceFixture>({ | |
| }, | ||
|
|
||
| // Extract the page from pageWithAuthenticatedUser and apply labs/stripe settings | ||
| page: async ({pageWithAuthenticatedUser, labs, stripeConnected}, use) => { | ||
| page: async ({pageWithAuthenticatedUser, labs}, use) => { | ||
| const page = pageWithAuthenticatedUser.page; | ||
| const settingsService = new SettingsService(page.request); | ||
|
|
||
| if (stripeConnected) { | ||
| debug('Setting up Stripe connection for test'); | ||
| await settingsService.setStripeConnected(); | ||
| } | ||
|
|
||
| const labsFlagsSpecified = labs && Object.keys(labs).length > 0; | ||
| if (labsFlagsSpecified) { | ||
| const settingsService = new SettingsService(page.request); | ||
| debug('Updating labs settings:', labs); | ||
| await settingsService.updateLabsSettings(labs); | ||
| } | ||
|
|
||
| const needsReload = stripeConnected || labsFlagsSpecified; | ||
| const needsReload = labsFlagsSpecified; | ||
| if (needsReload) { | ||
| await page.reload({waitUntil: 'load'}); | ||
| debug('Settings applied and page reloaded'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| import crypto from 'crypto'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: this file feels like it reinvents our Factory concept, which I think we should reuse here - e.g.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would like more discussion on this. I think we could rename them to factory-style names but I don't want them to live in the Services are distinct and I think there's limited use case (if any?) where we'd want to create Stripe data in a test that isn't via Ghost. Conceptually I think that's more where I draw the line. |
||
|
|
||
| function generateId(prefix: string): string { | ||
| return `${prefix}_${crypto.randomBytes(8).toString('hex')}`; | ||
| } | ||
|
|
||
| export interface StripeCustomer { | ||
| id: string; | ||
| object: 'customer'; | ||
| name: string; | ||
| email: string; | ||
| subscriptions: { | ||
| type: 'list'; | ||
| data: StripeSubscription[]; | ||
| }; | ||
| } | ||
|
|
||
| export interface StripePrice { | ||
| id: string; | ||
| object: 'price'; | ||
| unit_amount: number; | ||
| currency: string; | ||
| recurring: { | ||
| interval: string; | ||
| }; | ||
| product: string; | ||
| type: 'recurring'; | ||
| active: boolean; | ||
| nickname: string | null; | ||
| } | ||
|
|
||
| export interface StripePaymentMethod { | ||
| id: string; | ||
| object: 'payment_method'; | ||
| type: 'card'; | ||
| card: { | ||
| brand: string; | ||
| last4: string; | ||
| exp_month: number; | ||
| exp_year: number; | ||
| country: string; | ||
| }; | ||
| billing_details: { | ||
| name: string; | ||
| }; | ||
| } | ||
|
|
||
| export interface StripeSubscription { | ||
| id: string; | ||
| object: 'subscription'; | ||
| status: string; | ||
| cancel_at_period_end: boolean; | ||
| canceled_at: number | null; | ||
| current_period_end: number; | ||
| start_date: number; | ||
| default_payment_method: string | null; | ||
| items: { | ||
| type: 'list'; | ||
| data: Array<{price: StripePrice}>; | ||
| }; | ||
| customer: string; | ||
| } | ||
|
|
||
| export interface StripeEvent { | ||
| id: string; | ||
| object: 'event'; | ||
| type: string; | ||
| data: { | ||
| object: Record<string, unknown>; | ||
| }; | ||
| } | ||
|
|
||
| export function buildPrice(overrides: Partial<StripePrice> = {}): StripePrice { | ||
| return { | ||
| id: generateId('price'), | ||
| object: 'price', | ||
| unit_amount: 500, | ||
| currency: 'usd', | ||
| recurring: {interval: 'month'}, | ||
| product: generateId('prod'), | ||
| type: 'recurring', | ||
| active: true, | ||
| nickname: null, | ||
| ...overrides | ||
| }; | ||
| } | ||
|
|
||
| export function buildPaymentMethod(overrides: {id?: string; name?: string} = {}): StripePaymentMethod { | ||
| return { | ||
| id: overrides.id ?? generateId('pm'), | ||
| object: 'payment_method', | ||
| type: 'card', | ||
| card: { | ||
| brand: 'visa', | ||
| last4: '4242', | ||
| exp_month: 12, | ||
| exp_year: 2030, | ||
| country: 'US' | ||
| }, | ||
| billing_details: { | ||
| name: overrides.name ?? 'Test User' | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| export function buildCustomer(opts: {id?: string; email: string; name: string}): StripeCustomer { | ||
| return { | ||
| id: opts.id ?? generateId('cus'), | ||
| object: 'customer', | ||
| name: opts.name, | ||
| email: opts.email, | ||
| subscriptions: { | ||
| type: 'list', | ||
| data: [] | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| export function buildSubscription(opts: { | ||
| id?: string; | ||
| customerId: string; | ||
| priceId?: string; | ||
| productId?: string; | ||
| status?: string; | ||
| price?: StripePrice; | ||
| paymentMethod?: StripePaymentMethod | null; | ||
| }): StripeSubscription { | ||
| const price = opts.price ?? buildPrice({ | ||
| id: opts.priceId, | ||
| product: opts.productId ?? generateId('prod') | ||
| }); | ||
|
|
||
| return { | ||
| id: opts.id ?? generateId('sub'), | ||
| object: 'subscription', | ||
| status: opts.status ?? 'active', | ||
| cancel_at_period_end: false, | ||
| canceled_at: null, | ||
| current_period_end: Math.floor(Date.now() / 1000) + (60 * 60 * 24 * 31), | ||
| start_date: Math.floor(Date.now() / 1000), | ||
| default_payment_method: opts.paymentMethod?.id ?? null, | ||
| items: { | ||
| type: 'list', | ||
| data: [{price}] | ||
| }, | ||
| customer: opts.customerId | ||
| }; | ||
| } | ||
|
|
||
| export function buildCheckoutSessionCompletedEvent(opts: { | ||
| customerId: string; | ||
| metadata?: Record<string, string>; | ||
| }): StripeEvent { | ||
| return { | ||
| id: generateId('evt'), | ||
| object: 'event', | ||
| type: 'checkout.session.completed', | ||
| data: { | ||
| object: { | ||
| mode: 'subscription', | ||
| customer: opts.customerId, | ||
| metadata: { | ||
| checkoutType: 'signup', | ||
| ...(opts.metadata ?? {}) | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| export function buildSubscriptionCreatedEvent(opts: { | ||
| subscription: StripeSubscription; | ||
| }): StripeEvent { | ||
| return { | ||
| id: generateId('evt'), | ||
| object: 'event', | ||
| type: 'customer.subscription.created', | ||
| data: { | ||
| object: opts.subscription as unknown as Record<string, unknown> | ||
| } | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, this feels much cleaner 🙌