-
Notifications
You must be signed in to change notification settings - Fork 76
Validate Square payment arguments #625
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,8 +1,122 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { fakeBuildContext, makeVault } from '@profullstack/sh1pt-core/testing'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import target from './index.js'; | ||
|
|
||
| describe('payment-square', () => { | ||
| it('should have correct metadata', () => { | ||
| describe('payment-square target adapter', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it('has correct package metadata', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports | ||
| const pkg = require('../package.json'); | ||
| expect(pkg.name).toBe('@profullstack/sh1pt-target-payment-square'); | ||
| }); | ||
|
|
||
| it('creates payments with validated amount, currency, and sourceId', async () => { | ||
| const fetchMock = vi.fn().mockResolvedValue({ | ||
| ok: true, | ||
| json: async () => ({ payment: { id: 'pay_123' } }), | ||
| }); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| const result = await target.build(fakeBuildContext({ | ||
| secret: makeVault({ | ||
| SQUARE_ACCESS_TOKEN: 'square-token', | ||
| SQUARE_LOCATION_ID: 'loc_123', | ||
| }), | ||
| }) as any, { | ||
| command: 'create', | ||
| args: { amount: 1500, currency: 'usd', sourceId: 'cnon:card-nonce' }, | ||
| }); | ||
|
|
||
| expect(result).toEqual({ | ||
| artifact: 'square-payment-create', | ||
| meta: { raw: JSON.stringify({ payment: { id: 'pay_123' } }) }, | ||
| }); | ||
| expect(fetchMock).toHaveBeenCalledWith('https://connect.squareup.com/v2/payments', expect.objectContaining({ | ||
| method: 'POST', | ||
| headers: expect.objectContaining({ | ||
| Authorization: 'Bearer square-token', | ||
| 'Content-Type': 'application/json', | ||
| }), | ||
| })); | ||
| const body = JSON.parse(String(fetchMock.mock.calls[0]![1].body)); | ||
| expect(body).toMatchObject({ | ||
| source_id: 'cnon:card-nonce', | ||
| amount_money: { amount: 1500, currency: 'USD' }, | ||
| location_id: 'loc_123', | ||
| }); | ||
| }); | ||
|
|
||
| it('rejects invalid create amounts before calling Square', async () => { | ||
| const fetchMock = vi.fn(); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| await expect(target.build(fakeBuildContext({ | ||
| secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), | ||
| }) as any, { | ||
| command: 'create', | ||
| args: { amount: 1.5, currency: 'USD', sourceId: 'cnon:card-nonce' }, | ||
| })).rejects.toThrow('amount must be a positive integer'); | ||
|
|
||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rejects invalid currency codes before calling Square', async () => { | ||
| const fetchMock = vi.fn(); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| await expect(target.build(fakeBuildContext({ | ||
| secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), | ||
| }) as any, { | ||
| command: 'create', | ||
| args: { amount: 1500, currency: 'US', sourceId: 'cnon:card-nonce' }, | ||
| })).rejects.toThrow('currency must be a three-letter ISO code'); | ||
|
|
||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('requires a sourceId for create commands', async () => { | ||
| const fetchMock = vi.fn(); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| await expect(target.build(fakeBuildContext({ | ||
| secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), | ||
| }) as any, { | ||
| command: 'create', | ||
| args: { amount: 1500, currency: 'USD', sourceId: ' ' }, | ||
| })).rejects.toThrow('sourceId required'); | ||
|
|
||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('requires payment IDs for get, cancel, and refund commands', async () => { | ||
| const fetchMock = vi.fn(); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
| const ctx = fakeBuildContext({ | ||
| secret: makeVault({ SQUARE_ACCESS_TOKEN: 'square-token' }), | ||
| }) as any; | ||
|
|
||
| await expect(target.build(ctx, { | ||
| command: 'get', | ||
| args: { paymentId: ' ' }, | ||
| })).rejects.toThrow('paymentId required'); | ||
|
|
||
| await expect(target.build(ctx, { | ||
| command: 'cancel', | ||
| args: {}, | ||
| })).rejects.toThrow('paymentId required'); | ||
|
|
||
| await expect(target.build(ctx, { | ||
| command: 'refund', | ||
| args: {}, | ||
| })).rejects.toThrow('paymentId required'); | ||
|
|
||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,6 +10,24 @@ interface SquareError { | |||||||||||||||||||||||||||||||
| errors?: Array<{ code: string; detail: 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): number { | ||||||||||||||||||||||||||||||||
| if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { | ||||||||||||||||||||||||||||||||
| throw new Error(`${name} must be a positive integer`); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return value; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| function requireCurrency(value: unknown): string { | ||||||||||||||||||||||||||||||||
| const currency = typeof value === 'string' ? value.trim().toUpperCase() : 'USD'; | ||||||||||||||||||||||||||||||||
| if (!/^[A-Z]{3}$/.test(currency)) throw new Error('currency must be a three-letter ISO code'); | ||||||||||||||||||||||||||||||||
| return currency; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export default defineTarget<Config>({ | ||||||||||||||||||||||||||||||||
| id: 'payment-square', | ||||||||||||||||||||||||||||||||
| kind: 'payment', | ||||||||||||||||||||||||||||||||
|
|
@@ -41,9 +59,9 @@ export default defineTarget<Config>({ | |||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| switch (cmd) { | ||||||||||||||||||||||||||||||||
| case 'create': { | ||||||||||||||||||||||||||||||||
| const amount = config.args?.amount as number || 0; | ||||||||||||||||||||||||||||||||
| const currency = config.args?.currency as string || 'USD'; | ||||||||||||||||||||||||||||||||
| const sourceId = config.args?.sourceId as string || ''; | ||||||||||||||||||||||||||||||||
| const amount = requirePositiveInteger(config.args?.amount, 'amount'); | ||||||||||||||||||||||||||||||||
| const currency = requireCurrency(config.args?.currency); | ||||||||||||||||||||||||||||||||
| const sourceId = requireText(config.args?.sourceId, 'sourceId'); | ||||||||||||||||||||||||||||||||
| ctx.log(`square: creating payment of ${amount} ${currency}`); | ||||||||||||||||||||||||||||||||
| const data = await sq('/payments', { | ||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||
|
|
@@ -54,27 +72,27 @@ export default defineTarget<Config>({ | |||||||||||||||||||||||||||||||
| location_id: location, | ||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| return { output: JSON.stringify(data) }; | ||||||||||||||||||||||||||||||||
| return { artifact: 'square-payment-create', meta: { raw: JSON.stringify(data) } }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| case 'get': { | ||||||||||||||||||||||||||||||||
| const id = config.args?.paymentId as string || ''; | ||||||||||||||||||||||||||||||||
| const id = requireText(config.args?.paymentId, 'paymentId'); | ||||||||||||||||||||||||||||||||
| ctx.log(`square: getting payment ${id}`); | ||||||||||||||||||||||||||||||||
| const data = await sq(`/payments/${id}`); | ||||||||||||||||||||||||||||||||
| return { output: JSON.stringify(data) }; | ||||||||||||||||||||||||||||||||
| return { artifact: 'square-payment-get', meta: { raw: JSON.stringify(data) } }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| case 'cancel': { | ||||||||||||||||||||||||||||||||
| const id = config.args?.paymentId as string || ''; | ||||||||||||||||||||||||||||||||
| const id = requireText(config.args?.paymentId, 'paymentId'); | ||||||||||||||||||||||||||||||||
| ctx.log(`square: canceling payment ${id}`); | ||||||||||||||||||||||||||||||||
| const data = await sq(`/payments/${id}/cancel`, { method: 'POST' }); | ||||||||||||||||||||||||||||||||
| return { output: JSON.stringify(data) }; | ||||||||||||||||||||||||||||||||
| return { artifact: 'square-payment-cancel', meta: { raw: JSON.stringify(data) } }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| case 'list': { | ||||||||||||||||||||||||||||||||
| ctx.log('square: listing payments'); | ||||||||||||||||||||||||||||||||
| const data = await sq('/payments'); | ||||||||||||||||||||||||||||||||
| return { output: JSON.stringify(data) }; | ||||||||||||||||||||||||||||||||
| return { artifact: 'square-payment-list', meta: { raw: JSON.stringify(data) } }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| case 'refund': { | ||||||||||||||||||||||||||||||||
| const id = config.args?.paymentId as string || ''; | ||||||||||||||||||||||||||||||||
| const id = requireText(config.args?.paymentId, 'paymentId'); | ||||||||||||||||||||||||||||||||
| ctx.log(`square: refunding payment ${id}`); | ||||||||||||||||||||||||||||||||
| const data = await sq('/refunds', { | ||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||
|
|
@@ -85,7 +103,7 @@ export default defineTarget<Config>({ | |||||||||||||||||||||||||||||||
| reason: (config.args?.reason as string) || 'requested_by_customer', | ||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
Comment on lines
97
to
105
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.
Square's refund endpoint expects
Suggested change
|
||||||||||||||||||||||||||||||||
| return { output: JSON.stringify(data) }; | ||||||||||||||||||||||||||||||||
| return { artifact: 'square-payment-refund', meta: { raw: JSON.stringify(data) } }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||||||
| throw new Error(`Unknown command: ${cmd}`); | ||||||||||||||||||||||||||||||||
|
|
@@ -96,7 +114,7 @@ export default defineTarget<Config>({ | |||||||||||||||||||||||||||||||
| ctx.log('square: verifying setup'); | ||||||||||||||||||||||||||||||||
| const key = ctx.secret('SQUARE_ACCESS_TOKEN'); | ||||||||||||||||||||||||||||||||
| if (!key) { | ||||||||||||||||||||||||||||||||
| return setupGuide({ | ||||||||||||||||||||||||||||||||
| const setup = setupGuide({ | ||||||||||||||||||||||||||||||||
| title: 'Square Access Token', | ||||||||||||||||||||||||||||||||
| steps: [ | ||||||||||||||||||||||||||||||||
| '1. Go to https://developer.squareup.com/apps', | ||||||||||||||||||||||||||||||||
|
|
@@ -106,7 +124,8 @@ export default defineTarget<Config>({ | |||||||||||||||||||||||||||||||
| '5. Run: sh1pt secret set SQUARE_ACCESS_TOKEN <token>', | ||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| return { id: 'setup-required', meta: { setup } }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return { status: 'ready' }; | ||||||||||||||||||||||||||||||||
| return { id: 'ready', meta: { status: 'ready' } }; | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
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.
requireCurrencysilently defaults instead of rejectingWhen
valueis not a string — e.g.undefined,null, or a number — the function substitutes'USD'and passes the^[A-Z]{3}$check without throwing. Every otherrequire*helper in this file throws on an invalid/missing input, so callers expecting a validation error when they forget to supplycurrency(or pass the wrong type) will silently receive a USD charge instead. The inconsistency also means the function name "require" is misleading; a missing currency is accepted rather than rejected.