diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx index 2ea1da76ce7c4a..fdd44af4a7ff40 100644 --- a/static/app/types/integrations.tsx +++ b/static/app/types/integrations.tsx @@ -256,6 +256,8 @@ export type SentryApp = { id: number; slug: string; }; + // Each entry is a "Header-Name: value" line. Saved values are masked by the API + webhookHeaders?: string[]; }; // Minimal Sentry App representation for use with avatars diff --git a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx index 9fbcf6fedc611e..178aa0b37a386c 100644 --- a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx +++ b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.spec.tsx @@ -124,6 +124,7 @@ describe('Sentry Application Details', () => { verifyInstall: true, isAlertable: true, allowedOrigins: [], + webhookHeaders: [], schema: {}, overview: '', }; @@ -136,6 +137,38 @@ describe('Sentry Application Details', () => { }) ); }); + + it('saves webhook headers', async () => { + render(, { + initialRouterConfig, + organization: OrganizationFixture({ + features: ['sentry-apps-custom-webhook-headers'], + }), + }); + + await userEvent.type(screen.getByRole('textbox', {name: 'Name'}), 'Test App'); + await userEvent.type(screen.getByRole('textbox', {name: 'Author'}), 'Sentry'); + await userEvent.type( + screen.getByRole('textbox', {name: 'Webhook URL'}), + 'https://webhook.com' + ); + await userEvent.type( + screen.getByRole('textbox', {name: 'Webhook Headers'}), + 'X-Example: value' + ); + + await userEvent.click(screen.getByRole('button', {name: 'Save Changes'})); + + expect(createAppRequest).toHaveBeenCalledWith( + '/sentry-apps/', + expect.objectContaining({ + data: expect.objectContaining({ + webhookHeaders: ['X-Example: value'], + }), + method: 'POST', + }) + ); + }); }); describe('Creating a new internal Sentry App', () => { @@ -229,6 +262,25 @@ describe('Sentry Application Details', () => { expect(screen.getByRole('textbox', {name: 'Client ID'})).toBeInTheDocument(); expect(screen.getByRole('textbox', {name: 'Client Secret'})).toBeInTheDocument(); }); + + it('prefills webhook headers from the app', async () => { + sentryApp.webhookHeaders = ['X-Example: value', 'Another-Header: thing']; + MockApiClient.addMockResponse({ + url: `/sentry-apps/${sentryApp.slug}/`, + body: sentryApp, + }); + + render(, { + initialRouterConfig, + organization: OrganizationFixture({ + features: ['sentry-apps-custom-webhook-headers'], + }), + }); + + expect(await screen.findByRole('textbox', {name: 'Webhook Headers'})).toHaveValue( + 'X-Example: value\nAnother-Header: thing' + ); + }); }); describe('Renders for internal apps', () => { diff --git a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx index 7b2babc7d21b7d..1f69e26a038c17 100644 --- a/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx +++ b/static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx @@ -86,6 +86,7 @@ const sentryAppFormSchema = z name: z.string(), author: z.string(), webhookUrl: z.string(), + webhookHeaders: z.string(), redirectUrl: z.string(), verifyInstall: z.boolean(), isAlertable: z.boolean(), @@ -197,6 +198,7 @@ type SaveSentryAppPayload = { author?: string | null; overview?: string; redirectUrl?: string; + webhookHeaders?: string[]; webhookUrl?: string; }; @@ -514,6 +516,9 @@ function SentryApplicationForm({ schema: getSchemaFieldValue(app?.schema), overview: app?.overview ?? '', allowedOrigins: convertMultilineFieldValue(app?.allowedOrigins ?? []), + // Masked values (Header-Name: ***) round-trip safely: the backend preserves + // the stored value for any entry resubmitted with the mask sentinel. + webhookHeaders: convertMultilineFieldValue(app?.webhookHeaders ?? []), organization: organization.slug, isInternal, scopes: app ? [...app.scopes] : [], @@ -553,6 +558,7 @@ function SentryApplicationForm({ scopes: value.scopes, events: value.events, allowedOrigins: extractMultilineFields(value.allowedOrigins), + webhookHeaders: extractMultilineFields(value.webhookHeaders), schema: value.schema.trim() === '' ? {} : JSON.parse(value.schema), // The author parser doesn't allow_blank, so send null for empty // (covers internal apps with no author). @@ -689,6 +695,26 @@ function SentryApplicationForm({ )} + {organization.features.includes('sentry-apps-custom-webhook-headers') && ( + + {field => ( + + \nX-Custom-Header: value'} + /> + + )} + + )} + {!isInternal && ( {field => (