diff --git a/.changeset/app-webhook-trigger-json.md b/.changeset/app-webhook-trigger-json.md new file mode 100644 index 00000000000..c6557b24da3 --- /dev/null +++ b/.changeset/app-webhook-trigger-json.md @@ -0,0 +1,6 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +--- + +Add typed JSON output to `app webhook trigger`. diff --git a/.changeset/webhook-debug-secret.md b/.changeset/webhook-debug-secret.md new file mode 100644 index 00000000000..afe4a590321 --- /dev/null +++ b/.changeset/webhook-debug-secret.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-kit': patch +'@shopify/cli': patch +--- + +Hide webhook shared secrets in GraphQL request diagnostics. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 2191cb75c30..b5c0678f4dd 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4687,6 +4687,24 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-input", + "value": "''", + "description": "Disable interactive prompts and browser authentication.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_INPUT" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", "syntaxKind": "PropertySignature", @@ -4714,6 +4732,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_TOPIC" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", "syntaxKind": "PropertySignature", @@ -4722,9 +4749,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-webhook-trigger.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface appwebhooktrigger {\n /**\n * The URL where the webhook payload should be sent.\n You will need a different address type for each delivery-method:\n · For remote HTTP testing, use a URL that starts with https://\n · For local HTTP testing, use http://localhost:{port}/{url-path}\n · For Google Pub/Sub, use pubsub://{project-id}:{topic-id}\n · For Amazon EventBridge, use an Amazon Resource Name (ARN) starting with arn:aws:events:. Required if non interactive.\n * @environment SHOPIFY_FLAG_ADDRESS\n */\n '--address '?: string\n\n /**\n * The API Version of the webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version '?: string\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Your app's client secret. This secret allows us to return the X-Shopify-Hmac-SHA256 header that lets you validate the origin of the response that you receive.\n * @environment SHOPIFY_FLAG_CLIENT_SECRET\n */\n '--client-secret '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Method chosen to deliver the topic payload. If not passed, it's inferred from the address.\n * @environment SHOPIFY_FLAG_DELIVERY_METHOD\n */\n '--delivery-method '?: string\n\n /**\n * This help. When you run the trigger command the CLI will prompt you for any information that isn't passed using flags.\n * @environment SHOPIFY_FLAG_HELP\n */\n '--help'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The requested webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_TOPIC\n */\n '--topic '?: string\n}" + "value": "export interface appwebhooktrigger {\n /**\n * The URL where the webhook payload should be sent.\n You will need a different address type for each delivery-method:\n · For remote HTTP testing, use a URL that starts with https://\n · For local HTTP testing, use http://localhost:{port}/{url-path}\n · For Google Pub/Sub, use pubsub://{project-id}:{topic-id}\n · For Amazon EventBridge, use an Amazon Resource Name (ARN) starting with arn:aws:events:. Required if non interactive.\n * @environment SHOPIFY_FLAG_ADDRESS\n */\n '--address '?: string\n\n /**\n * The API Version of the webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_API_VERSION\n */\n '--api-version '?: string\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Your app's client secret. This secret allows us to return the X-Shopify-Hmac-SHA256 header that lets you validate the origin of the response that you receive.\n * @environment SHOPIFY_FLAG_CLIENT_SECRET\n */\n '--client-secret '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Method chosen to deliver the topic payload. If not passed, it's inferred from the address.\n * @environment SHOPIFY_FLAG_DELIVERY_METHOD\n */\n '--delivery-method '?: string\n\n /**\n * This help. When you run the trigger command the CLI will prompt you for any information that isn't passed using flags.\n * @environment SHOPIFY_FLAG_HELP\n */\n '--help'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The requested webhook topic. Required if non interactive.\n * @environment SHOPIFY_FLAG_TOPIC\n */\n '--topic '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "authlogin": { diff --git a/packages/app/src/cli/commands/app/webhook/trigger.test.ts b/packages/app/src/cli/commands/app/webhook/trigger.test.ts new file mode 100644 index 00000000000..948f3415868 --- /dev/null +++ b/packages/app/src/cli/commands/app/webhook/trigger.test.ts @@ -0,0 +1,99 @@ +import WebhookTrigger from './trigger.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {webhookTriggerService} from '../../../services/webhook/trigger.js' +import {WebhookTriggerResult} from '../../../services/webhook/trigger/types.js' +import { + testAppLinked, + testDeveloperPlatformClient, + testOrganization, + testOrganizationApp, + testProject, +} from '../../../models/app/app.test-data.js' +import {Config} from '@oclif/core' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {handler} from '@shopify/cli-kit/node/error' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/webhook/trigger.js') + +const app = testAppLinked() +const result: WebhookTriggerResult = { + status: 'success', + delivery: { + topic: 'orders/create', + apiVersion: '2026-10', + deliveryMethod: 'http', + address: 'https://example.com/webhooks', + status: 'enqueued', + }, + samplePayloadIsEmpty: true, +} + +beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient(), + organization: testOrganization(), + specifications: [], + project: testProject(), + activeConfig: {} as never, + }) +}) + +afterEach(() => vi.unstubAllEnvs()) + +async function runCommand(flags: string[] = ['--json']) { + const argv = [ + ...flags, + '--topic', + 'orders/create', + '--api-version', + '2026-10', + '--address', + 'https://example.com/webhooks', + ] + const command = new WebhookTrigger(argv, await Config.load()) + return runWithCommandEventsForCommand(argv, () => command.run()) +} + +test.each([['--json'], ['--json', '--no-input']])('writes one JSON result with flags %j', async (...flags) => { + vi.mocked(webhookTriggerService).mockImplementation(async () => { + outputInfo('Sending webhook sample.') + return result + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(flags) + expect(JSON.parse(stdout())).toEqual({status: 'success', delivery: result.delivery}) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', message: 'Sending webhook sample.'}) + }) +}) + +test.each([ + { + result: {status: 'failed', reason: 'sample-request', userErrors: [{message: 'Invalid topic', fields: ['topic']}]}, + error: { + type: 'abort', + message: 'Webhook sample request failed.', + details: {userErrors: [{message: 'Invalid topic', fieldPath: ['topic']}]}, + }, + }, + { + result: {status: 'failed', reason: 'localhost-delivery'}, + error: {type: 'abort', message: 'Localhost delivery failed'}, + }, +] satisfies {result: WebhookTriggerResult; error: object}[])( + 'writes one fatal JSON document for $result.reason', + async ({result, error}) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + vi.mocked(webhookTriggerService).mockResolvedValue(result) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand().catch(handler) + expect(JSON.parse(stdout())).toEqual({error}) + expect(stderr()).toBe('') + }) + }, +) diff --git a/packages/app/src/cli/commands/app/webhook/trigger.ts b/packages/app/src/cli/commands/app/webhook/trigger.ts index c69bb2532e1..050cbbbb87d 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.ts @@ -1,11 +1,13 @@ import {DELIVERY_METHOD} from '../../../services/webhook/trigger-flags.js' import {WebhookTriggerInput, webhookTriggerService} from '../../../services/webhook/trigger.js' +import {appWebhookTriggerJsonOutputSchema} from '../../../services/webhook/trigger/types.js' +import {renderWebhookTriggerResult} from '../../../services/webhook/trigger/result.js' import {deliveryMethodInstructionsAsString} from '../../../prompts/webhook/trigger.js' import {appFlags} from '../../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' import {Flags} from '@oclif/core' -import {requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' export default class WebhookTrigger extends AppLinkedCommand { static summary = 'Trigger delivery of a sample webhook topic payload to a designated address.' @@ -27,10 +29,16 @@ export default class WebhookTrigger extends AppLinkedCommand { - You can't use this method to validate your API webhook subscriptions. ` + static get jsonOutputSchema() { + return appWebhookTriggerJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { + ...globalFlags, ...appFlags, + ...jsonFlag, help: Flags.help({ required: false, hidden: false, @@ -103,7 +111,8 @@ export default class WebhookTrigger extends AppLinkedCommand { organizationId: appContextResult.organization.id, } - await webhookTriggerService(usedFlags) + const result = await webhookTriggerService(usedFlags) + renderWebhookTriggerResult(result, flags.json ? 'json' : 'text') return {app: appContextResult.app} } } diff --git a/packages/app/src/cli/services/webhook/trigger.test.ts b/packages/app/src/cli/services/webhook/trigger.test.ts index 24e82289b47..cf8c47e3682 100644 --- a/packages/app/src/cli/services/webhook/trigger.test.ts +++ b/packages/app/src/cli/services/webhook/trigger.test.ts @@ -3,6 +3,7 @@ import {SendSampleWebhookVariables, getWebhookSample} from './request-sample.js' import {requestApiVersions} from './request-api-versions.js' import {requestTopics} from './request-topics.js' import {triggerLocalWebhook} from './trigger-local-webhook.js' +import {renderWebhookTriggerResult} from './trigger/result.js' import { testApp, testAppLinked, @@ -79,10 +80,11 @@ describe('webhookTriggerService', () => { vi.mocked(getWebhookSample).mockResolvedValue(response) // When - await webhookTriggerService(sampleFlags()) + const result = await webhookTriggerService(sampleFlags()) // Then expectCalls(aVersion, anOrganizationId) + renderWebhookTriggerResult(result, 'text') expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n · Some error\n · Another error`) }) @@ -102,10 +104,11 @@ describe('webhookTriggerService', () => { vi.mocked(getWebhookSample).mockResolvedValue(response) // When - await webhookTriggerService(sampleFlags()) + const result = await webhookTriggerService(sampleFlags()) // Then expectCalls(aVersion, anOrganizationId) + renderWebhookTriggerResult(result, 'text') expect(outputWarn).toHaveBeenCalledWith(`Request errors:\n${JSON.stringify(response.userErrors)}`) }) @@ -123,7 +126,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(sampleFlags()) + const result = await webhookTriggerService(sampleFlags()) // Then expectCalls(aVersion, anOrganizationId) @@ -133,6 +136,18 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledTimes(0) + expect(result).toEqual({ + status: 'success', + delivery: { + topic: aTopic, + apiVersion: aVersion, + deliveryMethod: expectedSampleWebhookVariables.delivery_method, + address: expectedSampleWebhookVariables.address, + status: 'enqueued', + }, + samplePayloadIsEmpty: true, + }) + renderWebhookTriggerResult(result, 'text') expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') }) @@ -176,7 +191,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(flags) + const result = await webhookTriggerService(flags) // Then expectCalls(aVersion, anOrganizationId) @@ -185,6 +200,7 @@ describe('webhookTriggerService', () => { expectedSampleWebhookVariables, anOrganizationId, ) + renderWebhookTriggerResult(result, 'text') expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') }) @@ -203,7 +219,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(sampleLocalhostFlags()) + const result = await webhookTriggerService(sampleLocalhostFlags()) // Then expectCalls(aVersion, anOrganizationId) @@ -213,6 +229,8 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) + expect(result).toMatchObject({status: 'success', delivery: {status: 'delivered'}}) + renderWebhookTriggerResult(result, 'text') expect(outputSuccess).toHaveBeenCalledWith('Localhost delivery sucessful') }) @@ -230,7 +248,7 @@ describe('webhookTriggerService', () => { } // When - await webhookTriggerService(sampleLocalhostFlags()) + const result = await webhookTriggerService(sampleLocalhostFlags()) // Then expectCalls(aVersion, anOrganizationId) @@ -240,6 +258,8 @@ describe('webhookTriggerService', () => { anOrganizationId, ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) + expect(result).toEqual({status: 'failed', reason: 'localhost-delivery'}) + renderWebhookTriggerResult(result, 'text') expect(outputWarn).toHaveBeenCalledWith('Localhost delivery failed') }) }) diff --git a/packages/app/src/cli/services/webhook/trigger.ts b/packages/app/src/cli/services/webhook/trigger.ts index 45677cef1ae..d8691d45d84 100644 --- a/packages/app/src/cli/services/webhook/trigger.ts +++ b/packages/app/src/cli/services/webhook/trigger.ts @@ -1,11 +1,11 @@ import {DELIVERY_METHOD} from './trigger-flags.js' -import {getWebhookSample, SendSampleWebhookVariables, UserErrors} from './request-sample.js' +import {getWebhookSample, SendSampleWebhookVariables} from './request-sample.js' import {triggerLocalWebhook} from './trigger-local-webhook.js' import {collectAddressAndMethod, collectApiVersion, collectCredentials, collectTopic} from './trigger-options.js' +import {AppWebhookTriggerResult, WebhookTriggerResult} from './trigger/types.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppLinkedInterface} from '../../models/app/app.js' import {OrganizationApp} from '../../models/organization.js' -import {outputWarn, outputSuccess} from '@shopify/cli-kit/node/output' export interface WebhookTriggerInput { app: AppLinkedInterface @@ -36,14 +36,13 @@ interface WebhookTriggerOptions { /** * Orchestrates the command request by collecting params, requesting the sample, and sending it to localhost if * required. - * It outputs the result * * @param flags - Passed flags */ -export async function webhookTriggerService(input: WebhookTriggerInput) { +export async function webhookTriggerService(input: WebhookTriggerInput): Promise { const options: WebhookTriggerOptions = await validateAndCollectFlags(input) - await sendSample(options) + return sendSample(options) } async function validateAndCollectFlags(input: WebhookTriggerInput): Promise { @@ -64,7 +63,7 @@ async function validateAndCollectFlags(input: WebhookTriggerInput): Promise { const variables: SendSampleWebhookVariables = { topic: options.topic, api_version: options.apiVersion, @@ -76,38 +75,34 @@ async function sendSample(options: WebhookTriggerOptions) { const sample = await getWebhookSample(options.developerPlatformClient, variables, options.organizationId) if (!sample.success) { - outputWarn(`Request errors:\n${formatErrors(sample.userErrors)}`) - return + return {status: 'failed', reason: 'sample-request', userErrors: sample.userErrors} + } + + const delivery: AppWebhookTriggerResult['delivery'] = { + topic: options.topic, + apiVersion: options.apiVersion, + deliveryMethod: options.deliveryMethod as AppWebhookTriggerResult['delivery']['deliveryMethod'], + address: options.address, + status: 'enqueued', } if (options.deliveryMethod === DELIVERY_METHOD.LOCALHOST) { const result = await triggerLocalWebhook(options.address, sample.samplePayload, sample.headers) if (result) { - outputSuccess('Localhost delivery sucessful') - return + return { + status: 'success', + delivery: {...delivery, status: 'delivered'}, + samplePayloadIsEmpty: sample.samplePayload === JSON.stringify({}), + } } - outputWarn('Localhost delivery failed') - return + return {status: 'failed', reason: 'localhost-delivery'} } - if (sample.samplePayload === JSON.stringify({})) { - outputSuccess('Webhook has been enqueued for delivery') - } -} - -function formatErrors(errors: UserErrors[]): string { - try { - return errors - .map((element) => - JSON.parse(element.message) - .map((msg: string) => ` · ${msg}`) - .join('\n'), - ) - .join('\n') - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (err) { - return JSON.stringify(errors) + return { + status: 'success', + delivery, + samplePayloadIsEmpty: sample.samplePayload === JSON.stringify({}), } } diff --git a/packages/app/src/cli/services/webhook/trigger/result.ts b/packages/app/src/cli/services/webhook/trigger/result.ts new file mode 100644 index 00000000000..13ab17d1923 --- /dev/null +++ b/packages/app/src/cli/services/webhook/trigger/result.ts @@ -0,0 +1,47 @@ +import {appWebhookTriggerJsonOutputSchema, WebhookTriggerResult} from './types.js' +import {UserErrors} from '../request-sample.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult, outputSuccess, outputWarn} from '@shopify/cli-kit/node/output' + +export function renderWebhookTriggerResult(result: WebhookTriggerResult, format: 'json' | 'text'): void { + if (result.status === 'failed') { + if (format === 'json') { + const error = new AbortError( + result.reason === 'sample-request' ? 'Webhook sample request failed.' : 'Localhost delivery failed', + ) + if (result.reason === 'sample-request') { + error.details = {userErrors: result.userErrors.map(({message, fields}) => ({message, fieldPath: fields}))} + } + throw error + } + outputWarn( + result.reason === 'sample-request' + ? `Request errors:\n${formatErrors(result.userErrors)}` + : 'Localhost delivery failed', + ) + return + } + + if (format === 'json') { + outputResult(appWebhookTriggerJsonOutputSchema.encode({status: result.status, delivery: result.delivery})) + } else if (result.delivery.status === 'delivered') { + outputSuccess('Localhost delivery sucessful') + } else if (result.samplePayloadIsEmpty) { + outputSuccess('Webhook has been enqueued for delivery') + } +} + +function formatErrors(errors: UserErrors[]): string { + try { + return errors + .map((element) => + JSON.parse(element.message) + .map((msg: string) => ` · ${msg}`) + .join('\n'), + ) + .join('\n') + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (err) { + return JSON.stringify(errors) + } +} diff --git a/packages/app/src/cli/services/webhook/trigger/types.test.ts b/packages/app/src/cli/services/webhook/trigger/types.test.ts new file mode 100644 index 00000000000..bcc37c9dc1b --- /dev/null +++ b/packages/app/src/cli/services/webhook/trigger/types.test.ts @@ -0,0 +1,19 @@ +import {appWebhookTriggerJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +test('rejects extra fields and unknown delivery methods', () => { + const delivery = { + topic: 'orders/create', + apiVersion: '2026-10', + deliveryMethod: 'http', + address: 'https://example.com/webhooks', + status: 'enqueued', + } + expect(() => appWebhookTriggerJsonOutputSchema.validate({status: 'success', delivery, secret: 'private'})).toThrow() + expect(() => + appWebhookTriggerJsonOutputSchema.validate({status: 'success', delivery: {...delivery, secret: 'private'}}), + ).toThrow() + expect(() => + appWebhookTriggerJsonOutputSchema.validate({status: 'success', delivery: {...delivery, deliveryMethod: 'unknown'}}), + ).toThrow() +}) diff --git a/packages/app/src/cli/services/webhook/trigger/types.ts b/packages/app/src/cli/services/webhook/trigger/types.ts new file mode 100644 index 00000000000..1f92a9b73fa --- /dev/null +++ b/packages/app/src/cli/services/webhook/trigger/types.ts @@ -0,0 +1,26 @@ +import {UserErrors} from '../request-sample.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const deliverySchema = zod + .object({ + topic: zod.string(), + apiVersion: zod.string(), + deliveryMethod: zod.enum(['localhost', 'http', 'google-pub-sub', 'event-bridge']), + address: zod.string(), + status: zod.enum(['delivered', 'enqueued']).describe('Remote delivery is enqueued, not confirmed received.'), + }) + .strict() + +export const appWebhookTriggerJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppWebhookTriggerResult', + schema: zod.object({status: zod.literal('success'), delivery: deliverySchema}).strict(), + definitions: {AppWebhookDelivery: deliverySchema}, +}) + +export type AppWebhookTriggerResult = InferJsonOutputSchema + +export type WebhookTriggerResult = + | (AppWebhookTriggerResult & {samplePayloadIsEmpty: boolean}) + | {status: 'failed'; reason: 'sample-request'; userErrors: UserErrors[]} + | {status: 'failed'; reason: 'localhost-delivery'} diff --git a/packages/cli-kit/src/private/node/api/graphql.test.ts b/packages/cli-kit/src/private/node/api/graphql.test.ts index 406f16243dd..b886b08eba9 100644 --- a/packages/cli-kit/src/private/node/api/graphql.test.ts +++ b/packages/cli-kit/src/private/node/api/graphql.test.ts @@ -1,8 +1,36 @@ -import {extractGraphQLErrorMessages, errorHandler} from './graphql.js' +import {debugLogRequestInfo, extractGraphQLErrorMessages, errorHandler} from './graphql.js' import {GraphQLClientError} from './headers.js' import {AbortError} from '../../../public/node/error.js' +import {runWithCommandEventsForCommand} from '../../../public/node/command-events.js' +import {withCapturedStandardStreams} from '../../../public/node/testing/output.js' +import * as localContext from '../../../public/node/context/local.js' import {ClientError} from 'graphql-request' -import {describe, expect, test} from 'vitest' +import {describe, expect, test, vi} from 'vitest' + +test('masks webhook secrets in diagnostics without changing request variables', async () => { + const verbose = vi.spyOn(localContext, 'isVerbose').mockReturnValue(true) + const variables = {sharedSecret: 'CURRENT_SECRET', shared_secret: 'LEGACY_SECRET'} + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + debugLogRequestInfo( + 'Webhooks', + 'mutation { cliTesting { success } }', + 'https://example.com/graphql', + variables, + ), + ) + expect(stdout()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'debug'}) + expect(stderr()).toContain('*****') + expect(stderr()).not.toContain('CURRENT_SECRET') + expect(stderr()).not.toContain('LEGACY_SECRET') + }) + } finally { + verbose.mockRestore() + } + expect(variables).toEqual({sharedSecret: 'CURRENT_SECRET', shared_secret: 'LEGACY_SECRET'}) +}) describe('extractGraphQLErrorMessages', () => { test('returns undefined for undefined errors', () => { diff --git a/packages/cli-kit/src/private/node/api/graphql.ts b/packages/cli-kit/src/private/node/api/graphql.ts index 40d272258df..9d99517f271 100644 --- a/packages/cli-kit/src/private/node/api/graphql.ts +++ b/packages/cli-kit/src/private/node/api/graphql.ts @@ -22,7 +22,7 @@ to ${sanitizeURL(url)}`) export function sanitizeVariables(variables: Variables): string { const result: Variables = {...variables} - const sensitiveKeys = ['apiKey', 'serialized_script'] + const sensitiveKeys = ['apiKey', 'serialized_script', 'sharedSecret', 'shared_secret'] const sanitizedResult = sanitizeDeepVariables(result, sensitiveKeys) diff --git a/packages/cli/README.md b/packages/cli/README.md index 102b71e50b7..486a25a0c9f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5774,14 +5774,18 @@ Trigger delivery of a sample webhook topic payload to a designated address. ``` USAGE $ shopify app webhook trigger [--address ] [--api-version ] [--auth-alias ] [--client-id | - -c ] [--client-secret ] [--delivery-method http|google-pub-sub|event-bridge] [--help] [--json-schema] - [--path ] [--reset | ] [--topic ] + -c ] [--client-secret ] [--delivery-method http|google-pub-sub|event-bridge] [--help] [-j] + [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [--topic ] [--verbose] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --address= The URL where the webhook payload should be sent. You will need a different address type for each delivery-method: @@ -5823,6 +5827,14 @@ FLAGS Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --no-input + Disable interactive prompts and browser authentication. + [env: SHOPIFY_FLAG_NO_INPUT] + --path= The path to your app directory. [env: SHOPIFY_FLAG_PATH] @@ -5835,6 +5847,10 @@ FLAGS The requested webhook topic. Required if non interactive. [env: SHOPIFY_FLAG_TOPIC] + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + DESCRIPTION Trigger delivery of a sample webhook topic payload to a designated address. @@ -5860,6 +5876,74 @@ DESCRIPTION - Trigger requests are rate-limited using the "Partner API rate limit" (https://shopify.dev/docs/api/partner#rate_limits). - You can't use this method to validate your API webhook subscriptions. + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppWebhookTriggerResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "delivery": { + "$ref": "#/definitions/AppWebhookDelivery" + } + }, + "required": [ + "status", + "delivery" + ], + "additionalProperties": false, + "title": "AppWebhookTriggerResult", + "definitions": { + "AppWebhookDelivery": { + "type": "object", + "properties": { + "topic": { + "type": "string" + }, + "apiVersion": { + "type": "string" + }, + "deliveryMethod": { + "type": "string", + "enum": [ + "localhost", + "http", + "google-pub-sub", + "event-bridge" + ] + }, + "address": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "delivered", + "enqueued" + ], + "description": "Remote delivery is enqueued, not confirmed received." + } + }, + "required": [ + "topic", + "apiVersion", + "deliveryMethod", + "address", + "status" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify auth login` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index f3e1faecad1..3cb9ef6de83 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5403,7 +5403,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "\n Triggers the delivery of a sample Admin API event topic payload to a designated address.\n\n You should use this command to experiment with webhooks, to initially test your webhook configuration, or for unit testing. However, to test your webhook configuration from end to end, you should always trigger webhooks by performing the related action in Shopify.\n\n Because most webhook deliveries use remote endpoints, you can trigger the command from any directory where you can use Shopify CLI, and send the webhook to any of the supported endpoint types. For example, you can run the command from your app's local directory, but send the webhook to a staging environment endpoint.\n\n To learn more about using webhooks in a Shopify app, refer to \"Webhooks overview\" (https://shopify.dev/docs/apps/webhooks).\n\n ### Limitations\n\n - Webhooks triggered using this method always have the same payload, so they can't be used to test scenarios that differ based on the payload contents.\n - Webhooks triggered using this method aren't retried when they fail.\n - Trigger requests are rate-limited using the \"Partner API rate limit\" (https://shopify.dev/docs/api/partner#rate_limits).\n - You can't use this method to validate your API webhook subscriptions.\n ", + "description": "\n Triggers the delivery of a sample Admin API event topic payload to a designated address.\n\n You should use this command to experiment with webhooks, to initially test your webhook configuration, or for unit testing. However, to test your webhook configuration from end to end, you should always trigger webhooks by performing the related action in Shopify.\n\n Because most webhook deliveries use remote endpoints, you can trigger the command from any directory where you can use Shopify CLI, and send the webhook to any of the supported endpoint types. For example, you can run the command from your app's local directory, but send the webhook to a staging environment endpoint.\n\n To learn more about using webhooks in a Shopify app, refer to \"Webhooks overview\" (https://shopify.dev/docs/apps/webhooks).\n\n ### Limitations\n\n - Webhooks triggered using this method always have the same payload, so they can't be used to test scenarios that differ based on the payload contents.\n - Webhooks triggered using this method aren't retried when they fail.\n - Trigger requests are rate-limited using the \"Partner API rate limit\" (https://shopify.dev/docs/api/partner#rate_limits).\n - You can't use this method to validate your API webhook subscriptions.\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppWebhookTriggerResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"delivery\": {\n \"$ref\": \"#/definitions/AppWebhookDelivery\"\n }\n },\n \"required\": [\n \"status\",\n \"delivery\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppWebhookTriggerResult\",\n \"definitions\": {\n \"AppWebhookDelivery\": {\n \"type\": \"object\",\n \"properties\": {\n \"topic\": {\n \"type\": \"string\"\n },\n \"apiVersion\": {\n \"type\": \"string\"\n },\n \"deliveryMethod\": {\n \"type\": \"string\",\n \"enum\": [\n \"localhost\",\n \"http\",\n \"google-pub-sub\",\n \"event-bridge\"\n ]\n },\n \"address\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"delivered\",\n \"enqueued\"\n ],\n \"description\": \"Remote delivery is enqueued, not confirmed received.\"\n }\n },\n \"required\": [\n \"topic\",\n \"apiVersion\",\n \"deliveryMethod\",\n \"address\",\n \"status\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "\n Triggers the delivery of a sample Admin API event topic payload to a designated address.\n\n You should use this command to experiment with webhooks, to initially test your webhook configuration, or for unit testing. However, to test your webhook configuration from end to end, you should always trigger webhooks by performing the related action in Shopify.\n\n Because most webhook deliveries use remote endpoints, you can trigger the command from any directory where you can use Shopify CLI, and send the webhook to any of the supported endpoint types. For example, you can run the command from your app's local directory, but send the webhook to a staging environment endpoint.\n\n To learn more about using webhooks in a Shopify app, refer to [Webhooks overview](https://shopify.dev/docs/apps/webhooks).\n\n ### Limitations\n\n - Webhooks triggered using this method always have the same payload, so they can't be used to test scenarios that differ based on the payload contents.\n - Webhooks triggered using this method aren't retried when they fail.\n - Trigger requests are rate-limited using the [Partner API rate limit](https://shopify.dev/docs/api/partner#rate_limits).\n - You can't use this method to validate your API webhook subscriptions.\n ", "flags": { "address": { @@ -5490,6 +5490,15 @@ "required": false, "type": "boolean" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", @@ -5497,6 +5506,21 @@ "name": "json-schema", "type": "boolean" }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "no-input": { + "allowNo": false, + "description": "Disable interactive prompts and browser authentication.", + "env": "SHOPIFY_FLAG_NO_INPUT", + "name": "no-input", + "type": "boolean" + }, "path": { "description": "The path to your app directory.", "env": "SHOPIFY_FLAG_PATH", @@ -5526,6 +5550,14 @@ "name": "topic", "required": false, "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" } }, "hasDynamicHelp": false, diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index bd4c9b82a8b..957df2772af 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -21,7 +21,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/import/dashboard-extensions.ts', 'packages/app/src/cli/commands/app/init.ts', 'packages/app/src/cli/commands/app/release.ts', - 'packages/app/src/cli/commands/app/webhook/trigger.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts',