Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions e2e/helpers/environment/environment-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,13 @@ export class EnvironmentManager {
/**
* Per-test setup - creates containers on first call, then clones database and restarts Ghost.
*/
async perTestSetup(options: {config?: GhostEnvOverrides} = {}): Promise<GhostInstance> {
async perTestSetup(options: {
config?: GhostEnvOverrides;
stripe?: {
secretKey: string;
publishableKey: string;
};
} = {}): Promise<GhostInstance> {
// Lazy initialization of Ghost containers (once per worker)
if (!this.initialized) {
debug('Initializing Ghost containers for worker', this.workerIndex, 'in mode', this.mode);
Expand All @@ -108,7 +114,9 @@ export class EnvironmentManager {
const instanceId = `ghost_e2e_${siteUuid.replace(/-/g, '_')}`;

// Setup database
await this.mysql.setupTestDatabase(instanceId, siteUuid);
await this.mysql.setupTestDatabase(instanceId, siteUuid, {
stripe: options.stripe
});

// Restart Ghost with new database
await this.ghost.restartWithDatabase(instanceId, options.config);
Expand Down
30 changes: 29 additions & 1 deletion e2e/helpers/environment/service-managers/mysql-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,20 @@ export class MySQLManager {
this.containerName = containerName;
}

async setupTestDatabase(databaseName: string, siteUuid: string): Promise<void> {
async setupTestDatabase(databaseName: string, siteUuid: string, options: {
stripe?: {
secretKey: string;
publishableKey: string;
};
} = {}): Promise<void> {
debug('Setting up test database:', databaseName);
try {
await this.createDatabase(databaseName);
await this.restoreDatabaseFromSnapshot(databaseName);
await this.updateSiteUuid(databaseName, siteUuid);
if (options.stripe) {
await this.updateStripeSettings(databaseName, options.stripe.secretKey, options.stripe.publishableKey);
}

debug('Test database setup completed:', databaseName, 'with site_uuid:', siteUuid);
} catch (error) {
Expand Down Expand Up @@ -167,6 +175,26 @@ export class MySQLManager {
debug('site_uuid updated in database settings:', siteUuid);
}

async updateStripeSettings(database: string, secretKey: string, publishableKey: string): Promise<void> {
Copy link
Copy Markdown
Collaborator

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 🙌

debug('Updating Stripe settings in database:', database);

const escapedSecretKey = secretKey.replace(/'/g, '\\\'');
const escapedPublishableKey = publishableKey.replace(/'/g, '\\\'');

// Use INSERT ... ON DUPLICATE KEY UPDATE so this works whether or not
// the settings rows already exist. In dev mode the base DB is empty
// (only schema, no seeded rows), so a plain UPDATE would be a no-op.
const command = 'mysql -uroot -proot -e "INSERT INTO \\`' + database + '\\`.settings ' +
'(id, \\`group\\`, \\`key\\`, value, type, flags, created_at, updated_at) VALUES ' +
'(SUBSTRING(REPLACE(UUID(), \'-\', \'\'), 1, 24), \'members\', \'stripe_secret_key\', \'' + escapedSecretKey + '\', \'string\', NULL, NOW(), NOW()), ' +
'(SUBSTRING(REPLACE(UUID(), \'-\', \'\'), 1, 24), \'members\', \'stripe_publishable_key\', \'' + escapedPublishableKey + '\', \'string\', NULL, NOW(), NOW()) ' +
'ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW();"';

await this.exec(command);

debug('Stripe settings updated in database');
}

private async exec(command: string) {
const container = this.docker.getContainer(this.containerName);
return await this.execInContainer(container, command);
Expand Down
69 changes: 55 additions & 14 deletions e2e/helpers/playwright/fixture.ts
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Gitleaks flags sk_test_e2eTestKey as a live Stripe secret key — add a suppression comment.

The key is intentionally fake, but its sk_test_ prefix matches Gitleaks' Stripe access-token rule (stripe-access-token), which will keep triggering [high] alerts in CI on every scan. Add an inline suppression to silence the false positive without granting a blanket override.

🛡️ 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
Verify each finding against the current code and only fix it if needed.

In `@e2e/helpers/playwright/fixture.ts` around lines 12 - 13, Gitleaks flags the
STRIPE_SECRET_KEY constant value because it matches the `stripe-access-token`
rule; add an inline suppression comment immediately next to the
STRIPE_SECRET_KEY declaration (the constant named STRIPE_SECRET_KEY in
e2e/helpers/playwright/fixture.ts) to mark it as an intentional fake key and
silence the false positive—e.g., add the repository’s accepted Gitleaks
suppression pragma/comment format (not a blanket ignore) referencing the rule or
a local-only exemption so the constant remains unchanged but CI no longer
reports it.


export interface User {
name: string;
email: string;
Expand All @@ -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;
Expand Down Expand Up @@ -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};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Maybe stripeEnabled should be part of the config? Not sure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stripe config is determined via the presence of the sk_ and pk_ keys. This handling has been updated so I think it's much more clear now as that data is able to be seeded in the db.

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,
Expand All @@ -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);
},
Expand Down Expand Up @@ -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');
Expand Down
182 changes: 182 additions & 0 deletions e2e/helpers/services/stripe/builders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import crypto from 'crypto';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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. StripeCustomerFactory, StripePriceFactory, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 /data-factory dir nor do I want them to be confused with the function of those factories.

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>
}
};
}
Loading
Loading