diff --git a/e2e/tests/notification-channels.spec.ts b/e2e/tests/notification-channels.spec.ts index 69ef3c3a9..c43916562 100644 --- a/e2e/tests/notification-channels.spec.ts +++ b/e2e/tests/notification-channels.spec.ts @@ -17,53 +17,49 @@ import {test, expect} from '@playwright/test'; import {loginAsUser, BASE_URL, expectDualThemeScreenshot} from './utils'; -test.beforeEach(async () => {}); +test('redirects unauthenticated user to home and shows toast', async ({ + page, +}) => { + await page.goto(`${BASE_URL}/settings/notification-channels`); + + // Expect to be redirected to the home page. + await expect(page).toHaveURL(`${BASE_URL}/`); + // FYI: We do not assert the toast because it flashes on the screen due to the redirect. +}); test.describe('Notification Channels Page', () => { - test('redirects unauthenticated user to home and shows toast', async ({ - page, - }) => { + test.beforeEach(async ({page}) => { + await loginAsUser(page, 'test user 1'); await page.goto(`${BASE_URL}/settings/notification-channels`); - - // Expect to be redirected to the home page. - await expect(page).toHaveURL(BASE_URL); - // FYI: We do not assert the toast because it flashes on the screen due to the redirect. }); test('authenticated user sees their email channel and coming soon messages', async ({ page, }) => { - // Log in as a test user - await loginAsUser(page, 'test user 1'); - - // Navigate to the notification channels page - await page.goto(`${BASE_URL}/settings/notification-channels`); - - // Move the mouse to a neutral position to avoid hover effects on the screenshot - await page.mouse.move(0, 0); - - // Expect the URL to be correct + // Expect the URL to be correct. await expect(page).toHaveURL(`${BASE_URL}/settings/notification-channels`); - // Verify Email panel content + // Verify Email panel content. const emailPanel = page.locator('webstatus-notification-email-channels'); await expect(emailPanel).toBeVisible(); await expect(emailPanel).toContainText('test.user.1@example.com'); await expect(emailPanel).toContainText('Enabled'); - // Verify RSS panel content + // Verify RSS panel content. const rssPanel = page.locator('webstatus-notification-rss-channels'); await expect(rssPanel).toBeVisible(); await expect(rssPanel).toContainText('Coming soon'); - // Verify Webhook panel content + // Verify Webhook panel content. const webhookPanel = page.locator( 'webstatus-notification-webhook-channels', ); await expect(webhookPanel).toBeVisible(); - await expect(webhookPanel).toContainText('Coming soon'); - // Take a screenshot for visual regression + // Move the mouse to a neutral position to avoid hover effects on the screenshot. + await page.mouse.move(0, 0); + + // Take a screenshot for visual regression. const pageContainer = page.locator('.page-container'); await expectDualThemeScreenshot( page, @@ -71,4 +67,139 @@ test.describe('Notification Channels Page', () => { 'notification-channels-authenticated', ); }); + + test('authenticated user can create and delete a slack webhook channel', async ({ + page, + }) => { + const nonce = Date.now(); + const webhookName = 'PlaywrightTestCreateDeleteTest ' + nonce; + const webhookUrl = + 'https://hooks.slack.com/services/PLAYWRIGHT/TEST/' + nonce; + + const webhookPanel = page.locator( + 'webstatus-notification-webhook-channels', + ); + + // Don't assert that no webhook channels are configured. + // There may be some from previous test runs or from manual testing. + + // Click Create button. + const createButton = webhookPanel.getByRole('button', { + name: 'Create Webhook channel', + }); + await expect(createButton).toBeVisible(); + await createButton.click(); + + // Fill the dialog. + const dialog = webhookPanel + .locator('webstatus-manage-notification-channel-dialog') + .locator('sl-dialog'); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('textbox', {name: 'Name'}).fill(webhookName); + await dialog + .getByRole('textbox', {name: 'Slack Webhook URL'}) + .fill(webhookUrl); + + await dialog.getByRole('button', {name: 'Create', exact: true}).click(); + + // Verify it's in the list. + await expect(dialog).not.toBeVisible(); + const channelItem = webhookPanel.locator('.channel-item', { + hasText: webhookName, + }); + await expect(channelItem).toBeVisible(); + + await channelItem.getByLabel('Delete').click(); + + const deleteDialog = webhookPanel.locator( + 'sl-dialog[label="Delete Webhook Channel"]', + ); + await expect(deleteDialog).toBeVisible(); + await deleteDialog + .getByRole('button', {name: 'Delete', exact: true}) + .click(); + + // Verify it's gone. + await expect(channelItem).not.toBeVisible(); + }); + + test('authenticated user can update a slack webhook channel', async ({ + page, + }) => { + // Use a nonce to make sure we don't have any stale data from previous test runs. + // Avoid using resetUserData() since it's an expensive operation. + const nonce = Date.now(); + const originalName = 'PlaywrightTestUpdateOriginal ' + nonce; + const originalUrl = + 'https://hooks.slack.com/services/PLAYWRIGHT/TEST/original-' + nonce; + const updatedName = 'PlaywrightTestUpdateUpdated ' + nonce; + const updatedUrl = + 'https://hooks.slack.com/services/PLAYWRIGHT/TEST/updated-' + nonce; + + // Create a channel first. + const webhookPanel = page.locator( + 'webstatus-notification-webhook-channels', + ); + await page.waitForLoadState('networkidle'); + await webhookPanel + .getByRole('button', {name: 'Create Webhook channel'}) + .click(); + const dialog = webhookPanel + .locator('webstatus-manage-notification-channel-dialog') + .locator('sl-dialog'); + await expect(dialog).toBeVisible({timeout: 10000}); + await dialog.getByRole('textbox', {name: 'Name'}).fill(originalName); + await dialog + .getByRole('textbox', {name: 'Slack Webhook URL'}) + .fill(originalUrl); + await dialog.getByRole('button', {name: 'Create', exact: true}).click(); + + // Verify it was created. + await expect(dialog).not.toBeVisible({timeout: 10000}); + const originalItem = webhookPanel.locator('.channel-item', { + hasText: originalName, + }); + await expect(originalItem).toBeVisible(); + + await originalItem.getByLabel('Edit').click(); + + // Verify current values in dialog. + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('textbox', {name: 'Name'})).toHaveValue( + originalName, + ); + await expect( + dialog.getByRole('textbox', {name: 'Slack Webhook URL'}), + ).toHaveValue(originalUrl); + + // Update the values. + await dialog.getByRole('textbox', {name: 'Name'}).fill(updatedName); + await dialog + .getByRole('textbox', {name: 'Slack Webhook URL'}) + .fill(updatedUrl); + + await dialog.getByRole('button', {name: 'Save', exact: true}).click(); + + // Verify it was updated. + await expect(dialog).not.toBeVisible({timeout: 10000}); + const updatedItem = webhookPanel.locator('.channel-item', { + hasText: updatedName, + }); + await expect(updatedItem).toBeVisible(); + await expect(originalItem).not.toBeVisible(); + + const deleteButton = updatedItem.locator('sl-button[aria-label="Delete"]'); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + + const deleteDialog = webhookPanel.locator( + 'sl-dialog[label="Delete Webhook Channel"]', + ); + await expect(deleteDialog).toBeVisible(); + await deleteDialog + .getByRole('button', {name: 'Delete', exact: true}) + .click(); + await expect(updatedItem).not.toBeVisible(); + }); }); diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png index f695dffbe..f61663204 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png index 947c5397e..437367629 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-chromium-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png index cafa34c16..cd3637c47 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-firefox-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png index 6366bbdb0..fc4054633 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-dark-webkit-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png index dfedc0e6d..2a198f7a8 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png differ diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png index c9ab38728..eed4608a2 100644 Binary files a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png differ diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts index 0470929a2..e709e4080 100644 --- a/frontend/src/static/js/api/client.ts +++ b/frontend/src/static/js/api/client.ts @@ -445,6 +445,87 @@ export class APIClient { }); } + public async createNotificationChannel( + token: string, + channel: components['schemas']['CreateNotificationChannelRequest'], + ): Promise { + const options: FetchOptions< + FilterKeys + > = { + headers: { + Authorization: `Bearer ${token}`, + }, + body: channel, + credentials: temporaryFetchOptions.credentials, + }; + const response = await this.client.POST( + '/v1/users/me/notification-channels', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + return response.data; + } + + public async updateNotificationChannel( + token: string, + channelId: string, + request: components['schemas']['UpdateNotificationChannelRequest'], + ): Promise { + const options: FetchOptions< + FilterKeys< + paths['/v1/users/me/notification-channels/{channel_id}'], + 'patch' + > + > = { + headers: { + Authorization: `Bearer ${token}`, + }, + params: { + path: { + channel_id: channelId, + }, + }, + body: request, + credentials: temporaryFetchOptions.credentials, + }; + const response = await this.client.PATCH( + '/v1/users/me/notification-channels/{channel_id}', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + return response.data; + } + + public async deleteNotificationChannel(token: string, channelId: string) { + const options = { + ...temporaryFetchOptions, + params: { + path: { + channel_id: channelId, + }, + }, + headers: { + Authorization: `Bearer ${token}`, + }, + }; + const response = await this.client.DELETE( + '/v1/users/me/notification-channels/{channel_id}', + options, + ); + const error = response.error; + if (error !== undefined) { + throw createAPIError(error); + } + + return response.data; + } + public async pingUser( token: string, pingOptions?: {githubToken?: string}, diff --git a/frontend/src/static/js/components/channel-config-registry.ts b/frontend/src/static/js/components/channel-config-registry.ts new file mode 100644 index 000000000..916fea439 --- /dev/null +++ b/frontend/src/static/js/components/channel-config-registry.ts @@ -0,0 +1,51 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {html, TemplateResult} from 'lit'; +import {type components} from 'webstatus.dev-backend'; +import './webhook-config-form.js'; + +import {ChannelConfigUpdate} from './channel-config-types.js'; + +type ChannelType = components['schemas']['NotificationChannel']['type']; +type ChannelResponse = components['schemas']['NotificationChannelResponse']; + +export const ChannelConfigRegistry = { + renderConfig( + type: ChannelType, + channel: ChannelResponse | undefined, + onUpdate: (update: ChannelConfigUpdate) => void, + ): TemplateResult { + switch (type) { + case 'webhook': + return html`) => onUpdate(e.detail)} + >`; + case 'email': + return html`
+ Email: + ${channel?.config.type === 'email' + ? (channel.config as components['schemas']['EmailConfig']).address + : ''} + (Verified) +
`; + default: + return html`

Unsupported channel type: ${type}

`; + } + }, +}; diff --git a/frontend/src/static/js/components/channel-config-types.ts b/frontend/src/static/js/components/channel-config-types.ts new file mode 100644 index 000000000..c94bdbfe2 --- /dev/null +++ b/frontend/src/static/js/components/channel-config-types.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {components} from 'webstatus.dev-backend'; +import {LitElement} from 'lit'; +import {property} from 'lit/decorators.js'; + +type UpdateRequest = components['schemas']['UpdateNotificationChannelRequest']; +type UpdateMask = UpdateRequest['update_mask'][number]; +type ChannelResponse = components['schemas']['NotificationChannelResponse']; + +export interface ChannelConfigUpdate { + updates: Partial; + mask: UpdateMask[]; +} + +export interface ChannelConfigComponent extends HTMLElement { + channel?: ChannelResponse; + getUpdate(): ChannelConfigUpdate; + isDirty(): boolean; + validate(): boolean; +} + +export abstract class ChannelConfigForm extends LitElement { + @property({type: Object}) abstract channel?: ChannelResponse; + abstract validate(): boolean; +} diff --git a/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts index 933cda626..526c9c5d8 100644 --- a/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts +++ b/frontend/src/static/js/components/test/webstatus-notification-webhook-channels.test.ts @@ -20,21 +20,22 @@ import '../../components/webstatus-notification-webhook-channels.js'; import '../../components/webstatus-notification-panel.js'; describe('webstatus-notification-webhook-channels', () => { - it('displays "Coming soon" message', async () => { + it('displays "No webhook channels configured." message', async () => { const el = await fixture(html` `); - const basePanel = el.shadowRoot!.querySelector( 'webstatus-notification-panel', ); assert.isNotNull(basePanel); - - const comingSoonText = basePanel!.querySelector( + const noChannelsText = basePanel!.querySelector( '[slot="content"] p', ) as HTMLParagraphElement; - assert.isNotNull(comingSoonText); - assert.include(comingSoonText.textContent, 'Coming soon'); + assert.isNotNull(noChannelsText); + assert.include( + noChannelsText.textContent, + 'No webhook channels configured.', + ); }); it('displays "Create Webhook channel" button', async () => { diff --git a/frontend/src/static/js/components/webhook-config-form.ts b/frontend/src/static/js/components/webhook-config-form.ts new file mode 100644 index 000000000..6ac346015 --- /dev/null +++ b/frontend/src/static/js/components/webhook-config-form.ts @@ -0,0 +1,152 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {css, html} from 'lit'; +import {customElement, property, state, query} from 'lit/decorators.js'; +import {components} from 'webstatus.dev-backend'; +import {SlInput} from '@shoelace-style/shoelace'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import { + ChannelConfigComponent, + ChannelConfigUpdate, + ChannelConfigForm, +} from './channel-config-types.js'; + +type ChannelResponse = components['schemas']['NotificationChannelResponse']; +type WebhookConfig = components['schemas']['WebhookConfig']; +type UpdateRequest = components['schemas']['UpdateNotificationChannelRequest']; +type UpdateMask = UpdateRequest['update_mask'][number]; + +@customElement('webhook-config-form') +export class WebhookConfigForm + extends ChannelConfigForm + implements ChannelConfigComponent +{ + @property({type: Object}) channel?: ChannelResponse; + + protected get config(): WebhookConfig | undefined { + const config = this.channel?.config; + return config?.type === 'webhook' ? config : undefined; + } + + @state() private _pendingName?: string; + @state() private _pendingUrl?: string; + + @query('#webhook-name') + private _nameInput!: SlInput; + + @query('#webhook-url') + private _urlInput!: SlInput; + + static styles = [ + SHARED_STYLES, + css` + :host { + display: flex; + flex-direction: column; + gap: 16px; + } + .help-text { + font-size: 12px; + color: var(--unimportant-text-color); + margin: 0; + } + `, + ]; + + isDirty(): boolean { + const currentName = this.channel?.name ?? ''; + const currentUrl = this.config?.url ?? ''; + + const nameChanged = + this._pendingName !== undefined && this._pendingName !== currentName; + const urlChanged = + this._pendingUrl !== undefined && this._pendingUrl !== currentUrl; + + return nameChanged || urlChanged; + } + + validate(): boolean { + return this._nameInput.reportValidity() && this._urlInput.reportValidity(); + } + + getUpdate(): ChannelConfigUpdate { + const updates: Partial = {}; + const mask: UpdateMask[] = []; + + const currentName = this.channel?.name ?? ''; + const nameToUse = this._pendingName ?? currentName; + + const currentUrl = this.config?.url ?? ''; + const urlToUse = this._pendingUrl ?? currentUrl; + + if (this._pendingName !== undefined || !this.channel) { + updates.name = nameToUse; + mask.push('name'); + } + + if (this._pendingUrl !== undefined || !this.channel) { + // For config updates, we must send the entire config object as it's a 'oneOf' in OpenAPI. + updates.config = { + type: 'webhook', + url: urlToUse, + }; + mask.push('config'); + } + + return {updates, mask}; + } + + private _handleInput() { + this._pendingName = this._nameInput.value; + this._pendingUrl = this._urlInput.value; + this.dispatchEvent( + new CustomEvent('change', { + detail: this.getUpdate(), + bubbles: true, + composed: true, + }), + ); + } + + render() { + const currentName = this.channel?.name ?? ''; + const currentUrl = this.config?.url ?? ''; + + return html` + + +

+ Currently only Slack incoming webhooks are supported. +

+ `; + } +} diff --git a/frontend/src/static/js/components/webstatus-manage-notification-channel-dialog.ts b/frontend/src/static/js/components/webstatus-manage-notification-channel-dialog.ts new file mode 100644 index 000000000..cf0b30c28 --- /dev/null +++ b/frontend/src/static/js/components/webstatus-manage-notification-channel-dialog.ts @@ -0,0 +1,118 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {LitElement, css, html} from 'lit'; +import {customElement, property, state, query} from 'lit/decorators.js'; +import {components} from 'webstatus.dev-backend'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import { + ChannelConfigUpdate, + ChannelConfigComponent, +} from './channel-config-types.js'; +import {ChannelConfigRegistry} from './channel-config-registry.js'; + +type ChannelType = components['schemas']['NotificationChannel']['type']; +type ChannelResponse = components['schemas']['NotificationChannelResponse']; + +@customElement('webstatus-manage-notification-channel-dialog') +export class ManageNotificationChannelDialog extends LitElement { + @property({type: Boolean}) open = false; + @property() mode: 'create' | 'edit' = 'create'; + @property() type: ChannelType = 'webhook'; + @property({type: Object}) channel?: ChannelResponse; + @property({type: Boolean}) loading = false; + + @state() private _pendingUpdate?: ChannelConfigUpdate; + + @query('.config-form') + private _configForm!: ChannelConfigComponent; + + static styles = [ + SHARED_STYLES, + css` + sl-dialog::part(panel) { + width: min(90vw, 500px); + } + .dialog-body { + display: flex; + flex-direction: column; + gap: 16px; + } + `, + ]; + + private _handleHide() { + this.dispatchEvent(new CustomEvent('sl-hide')); + } + + private _handleSave() { + if (!this._configForm.validate()) return; + + this.dispatchEvent( + new CustomEvent('save', { + detail: { + mode: this.mode, + channelId: this.channel?.id, + ...this._pendingUpdate, + }, + }), + ); + } + + render() { + return html` + +
+ ${ChannelConfigRegistry.renderConfig( + this.mode === 'edit' ? this.channel!.type : this.type, + this.channel, + u => (this._pendingUpdate = u), + )} +
+ + ${this.mode === 'create' ? 'Create' : 'Save'} + + Cancel +
+ `; + } + + updated(changedProperties: Map) { + if (changedProperties.has('open') && !this.open) { + this._pendingUpdate = undefined; + } + } + + // Exposed for testing/querying the internal form. + get configForm(): ChannelConfigComponent { + const form = this.renderRoot.querySelector('.config-form'); + return (form as ChannelConfigComponent) || this._configForm; + } +} diff --git a/frontend/src/static/js/components/webstatus-notification-channels-page.ts b/frontend/src/static/js/components/webstatus-notification-channels-page.ts index a9d7a7bfc..f47d944de 100644 --- a/frontend/src/static/js/components/webstatus-notification-channels-page.ts +++ b/frontend/src/static/js/components/webstatus-notification-channels-page.ts @@ -56,6 +56,9 @@ export class WebstatusNotificationChannelsPage extends LitElement { @state() private emailChannels: NotificationChannelResponse[] = []; + @state() + private webhookChannels: NotificationChannelResponse[] = []; + private _channelsTask = new Task(this, { task: async () => { if (this.userContext === null) { @@ -79,6 +82,7 @@ export class WebstatusNotificationChannelsPage extends LitElement { return []; }); this.emailChannels = channels.filter(c => c.type === 'email'); + this.webhookChannels = channels.filter(c => c.type === 'webhook'); }, args: () => [this.userContext], }); @@ -91,11 +95,11 @@ export class WebstatusNotificationChannelsPage extends LitElement { - - - + + + `, complete: () => html` @@ -103,10 +107,13 @@ export class WebstatusNotificationChannelsPage extends LitElement { .channels=${this.emailChannels} > + this._channelsTask.run()} + > + - - `, error: e => { const errorMessage = diff --git a/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts index a78be6c55..b83491818 100644 --- a/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts +++ b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts @@ -15,17 +15,201 @@ */ import {LitElement, css, html} from 'lit'; -import {customElement} from 'lit/decorators.js'; +import {customElement, property, state} from 'lit/decorators.js'; +import {repeat} from 'lit/directives/repeat.js'; +import {consume} from '@lit/context'; +import {apiClientContext} from '../contexts/api-client-context.js'; +import {APIClient} from '../api/client.js'; +import { + UserContext, + firebaseUserContext, +} from '../contexts/firebase-user-context.js'; +import {components} from 'webstatus.dev-backend'; +import {SHARED_STYLES} from '../css/shared-css.js'; +import {toast} from '../utils/toast.js'; import './webstatus-notification-panel.js'; +import './webstatus-manage-notification-channel-dialog.js'; + +type NotificationChannelResponse = + components['schemas']['NotificationChannelResponse']; @customElement('webstatus-notification-webhook-channels') export class WebstatusNotificationWebhookChannels extends LitElement { - static styles = css` - .card-body { - padding: 20px; - color: var(--unimportant-text-color); + @consume({context: apiClientContext}) + @state() + apiClient!: APIClient; + + @consume({context: firebaseUserContext, subscribe: true}) + @state() + userContext: UserContext | null | undefined; + + @property({type: Array}) + channels: NotificationChannelResponse[] = []; + + @state() + private _isManageDialogOpen = false; + + @state() + private _manageDialogMode: 'create' | 'edit' = 'create'; + + @state() + private _selectedChannel?: NotificationChannelResponse; + + @state() + private _isSaving = false; + + @state() + private _isDeletingId: string | null = null; + + @state() + private _isDeleteDialogOpen = false; + + @state() + private _channelToDelete?: NotificationChannelResponse; + + static styles = [ + SHARED_STYLES, + css` + .channel-item { + color: var(--default-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-bottom: 1px solid var(--border-color); + } + + .channel-item:last-child { + border-bottom: none; + } + + .channel-info { + display: flex; + flex-direction: column; + overflow: hidden; + } + + .channel-info .name { + font-size: 14px; + font-weight: bold; + } + + .channel-info .url { + font-size: 12px; + color: var(--unimportant-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .actions { + display: flex; + align-items: center; + gap: 8px; + } + + .empty-message { + padding: 16px; + color: var(--unimportant-text-color); + } + `, + ]; + + private _openCreateDialog() { + this._manageDialogMode = 'create'; + this._selectedChannel = undefined; + this._isManageDialogOpen = true; + } + + private _openEditDialog(channel: NotificationChannelResponse) { + this._manageDialogMode = 'edit'; + this._selectedChannel = channel; + this._isManageDialogOpen = true; + } + + private _closeManageDialog() { + this._isManageDialogOpen = false; + } + + private async _handleSave(e: CustomEvent) { + if (!this.userContext) { + return; + } + const {mode, channelId, updates} = e.detail; + + this._isSaving = true; + try { + const token = await this.userContext.user.getIdToken(); + if (mode === 'create') { + const resp = await this.apiClient.createNotificationChannel( + token, + updates, + ); + void toast(`Created webhook channel "${resp.name}".`, 'success'); + } else { + const updateRequest: components['schemas']['UpdateNotificationChannelRequest'] = + { + ...updates, + update_mask: e.detail.mask, + }; + const resp = await this.apiClient.updateNotificationChannel( + token, + channelId, + updateRequest, + ); + void toast(`Updated webhook channel "${resp.name}".`, 'success'); + } + this.dispatchEvent( + new CustomEvent('channel-changed', {bubbles: true, composed: true}), + ); + this._closeManageDialog(); + } catch (e) { + void toast( + `Failed to ${mode} webhook channel. Please try again.`, + 'danger', + 'exclamation-triangle', + ); + console.error(`Failed to ${mode} webhook channel`, e); + } finally { + this._isSaving = false; + } + } + + private _handleDeleteClick(channel: NotificationChannelResponse) { + this._channelToDelete = channel; + this._isDeleteDialogOpen = true; + } + + private _closeDeleteDialog() { + this._isDeleteDialogOpen = false; + this._channelToDelete = undefined; + } + + private async _confirmDelete() { + if (!this.userContext || !this._channelToDelete) return; + + this._isDeletingId = this._channelToDelete.id; + try { + const token = await this.userContext.user.getIdToken(); + await this.apiClient.deleteNotificationChannel( + token, + this._channelToDelete.id, + ); + this.dispatchEvent( + new CustomEvent('channel-changed', {bubbles: true, composed: true}), + ); + this._closeDeleteDialog(); + } catch (e) { + void toast( + 'Failed to delete webhook channel. Please try again.', + 'danger', + 'exclamation-triangle', + ); + console.error('Failed to delete webhook channel', e); + } finally { + this._isDeletingId = null; } - `; + } render() { return html` @@ -33,15 +217,97 @@ export class WebstatusNotificationWebhookChannels extends LitElement { Webhook
- Create Webhook - channel + + + Create Webhook channel +
-

Coming soon

+ ${this.channels.length === 0 + ? html`

No webhook channels configured.

` + : repeat( + this.channels, + channel => channel.id, + channel => html` +
+
+ ${channel.name} + ${channel.config.type === 'webhook' + ? channel.config.url + : ''} +
+
+ ${channel.status === 'enabled' + ? html`Enabled` + : html`Disabled`} + this._openEditDialog(channel)} + > + + + this._handleDeleteClick(channel)} + > + + +
+
+ `, + )}
+ + + + + +

Are you sure you want to delete this webhook channel?

+ + Cancel + + + Delete + +
`; } } diff --git a/workers/webhook/pkg/webhook/mocks_test.go b/workers/webhook/pkg/webhook/mocks_test.go new file mode 100644 index 000000000..3fc4e01b0 --- /dev/null +++ b/workers/webhook/pkg/webhook/mocks_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "io" + "net/http" + "strings" + "time" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type mockHTTPClient struct { + doFunc func(req *http.Request) (*http.Response, error) +} + +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + return m.doFunc(req) +} + +type mockChannelStateManager struct { + successCalls []successCall + failureCalls []failureCall + recordErr error +} + +type successCall struct { + channelID string + timestamp time.Time + eventID string +} + +type failureCall struct { + channelID string + err error + timestamp time.Time + isPermanent bool + eventID string +} + +func (m *mockChannelStateManager) RecordSuccess(_ context.Context, channelID string, + timestamp time.Time, eventID string) error { + m.successCalls = append(m.successCalls, successCall{channelID, timestamp, eventID}) + + return m.recordErr +} + +func (m *mockChannelStateManager) RecordFailure(_ context.Context, channelID string, + err error, timestamp time.Time, isPermanent bool, eventID string) error { + m.failureCalls = append(m.failureCalls, failureCall{channelID, err, timestamp, isPermanent, eventID}) + + return m.recordErr +} + +func newTestIncomingWebhookDeliveryJob(url string, wType workertypes.WebhookType, + query string, summary []byte) workertypes.IncomingWebhookDeliveryJob { + return workertypes.IncomingWebhookDeliveryJob{ + WebhookEventID: "evt-123", + WebhookDeliveryJob: workertypes.WebhookDeliveryJob{ + ChannelID: "chan-1", + WebhookURL: url, + WebhookType: wType, + SubscriptionID: "sub-456", + Triggers: []workertypes.JobTrigger{}, + Metadata: workertypes.DeliveryMetadata{ + EventID: "evt-123", + SearchID: "search-789", + SearchName: "Test", + Query: query, + Frequency: workertypes.FrequencyWeekly, + GeneratedAt: testGeneratedAt(), + }, + SummaryRaw: summary, + }, + } +} + +func newTestResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + TransferEncoding: []string{}, + Close: false, + Uncompressed: false, + Trailer: make(http.Header), + Request: nil, + TLS: nil, + } +} + +func testGeneratedAt() time.Time { + return time.Date(2026, 3, 12, 0, 0, 0, 0, time.UTC) +} diff --git a/workers/webhook/pkg/webhook/sender.go b/workers/webhook/pkg/webhook/sender.go index 583e3653a..694415176 100644 --- a/workers/webhook/pkg/webhook/sender.go +++ b/workers/webhook/pkg/webhook/sender.go @@ -15,16 +15,14 @@ package webhook import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "log/slog" "net/http" - "net/url" "time" + "github.com/GoogleChrome/webstatus.dev/lib/event" "github.com/GoogleChrome/webstatus.dev/lib/workertypes" ) @@ -52,97 +50,69 @@ func NewSender(httpClient HTTPClient, stateManager ChannelStateManager, frontend } } -type SlackPayload struct { - Text string `json:"text"` -} +var ( + // ErrTransientWebhook is a transient failure that should be retried. + ErrTransientWebhook = errors.New("transient webhook failure") + // ErrPermanentWebhook is a permanent failure that should not be retried. + ErrPermanentWebhook = errors.New("permanent webhook failure") +) -type webhookPreparer interface { - Prepare(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) (*http.Request, error) +type webhookSender interface { + Send(ctx context.Context) error } -type slackPreparer struct { - frontendBaseURL string +// Manager wraps the type-specific webhook logic. +type Manager struct { + sender webhookSender } -func (s *slackPreparer) Prepare( - ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) (*http.Request, error) { - parsedURL, err := url.Parse(job.WebhookURL) - if err != nil || parsedURL.Scheme != "https" || parsedURL.Host != "hooks.slack.com" { - // Record permanent failure due to invalid URL - return nil, fmt.Errorf("invalid webhook URL: %s", job.WebhookURL) - } - - var summary workertypes.EventSummary - if err := json.Unmarshal(job.SummaryRaw, &summary); err != nil { - return nil, fmt.Errorf("failed to unmarshal summary: %w", err) - } - - resultsURL := fmt.Sprintf("%s/features?q=%s", s.frontendBaseURL, url.QueryEscape(job.Metadata.Query)) - - payload := SlackPayload{ - Text: fmt.Sprintf("WebStatus.dev Notification: %s\nQuery: %s\nView Results: %s", - summary.Text, job.Metadata.Query, resultsURL), - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("failed to marshal slack payload: %w", err) - } +func (s *Sender) getManager(_ context.Context, job workertypes.IncomingWebhookDeliveryJob) (*Manager, error) { + switch job.WebhookType { + case workertypes.WebhookTypeSlack: + slack, err := newSlackSender(s.frontendBaseURL, s.httpClient, job) + if err != nil { + return nil, err + } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, job.WebhookURL, bytes.NewBuffer(payloadBytes)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return &Manager{sender: slack}, nil + default: + return nil, fmt.Errorf("%w: unsupported type %v", ErrPermanentWebhook, job.WebhookType) } - req.Header.Set("Content-Type", "application/json") - - return req, nil } func (s *Sender) SendWebhook(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob) error { slog.InfoContext(ctx, "sending webhook", "channelID", job.ChannelID, "url", job.WebhookURL) - var preparer webhookPreparer - switch job.WebhookType { - case workertypes.WebhookTypeSlack: - preparer = &slackPreparer{frontendBaseURL: s.frontendBaseURL} - default: - err := fmt.Errorf("unsupported webhook type: %v", job.WebhookType) - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), true, job.WebhookEventID) - - return err - } - - req, err := preparer.Prepare(ctx, job) + mgr, err := s.getManager(ctx, job) if err != nil { - // Preparation failures (like invalid payload or URL format) are typically permanent - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), true, job.WebhookEventID) + // If we fail here, it's permanent when trying to get the manager. + s.recordFailure(ctx, job, err, true) - return fmt.Errorf("failed to prepare webhook request: %w", err) + return fmt.Errorf("failed to prepare webhook: %w", err) } - resp, err := s.httpClient.Do(req) - if err != nil { - // Transient error? - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), false, job.WebhookEventID) + if err := mgr.sender.Send(ctx); err != nil { + isTransient := errors.Is(err, ErrTransientWebhook) + s.recordFailure(ctx, job, err, !isTransient) + + if isTransient { + return errors.Join(event.ErrTransientFailure, err) + } return fmt.Errorf("failed to send webhook: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - // Success - _ = s.stateManager.RecordSuccess(ctx, job.ChannelID, time.Now(), job.WebhookEventID) - return nil + if err := s.stateManager.RecordSuccess(ctx, job.ChannelID, time.Now(), job.WebhookEventID); err != nil { + slog.WarnContext(ctx, "failed to record success", "error", err) } - // Failure - errorMsg := fmt.Sprintf("webhook returned status code %d", resp.StatusCode) - webhookErr := errors.New(errorMsg) - isPermanent := resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone || - resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden - - _ = s.stateManager.RecordFailure(ctx, job.ChannelID, webhookErr, time.Now(), isPermanent, job.WebhookEventID) + return nil +} - return fmt.Errorf("webhook failed: %s", errorMsg) +func (s *Sender) recordFailure(ctx context.Context, job workertypes.IncomingWebhookDeliveryJob, + err error, permanent bool) { + if dbErr := s.stateManager.RecordFailure(ctx, job.ChannelID, err, time.Now(), + permanent, job.WebhookEventID); dbErr != nil { + slog.ErrorContext(ctx, "failed to record failure", "error", dbErr) + } } diff --git a/workers/webhook/pkg/webhook/sender_test.go b/workers/webhook/pkg/webhook/sender_test.go index 589e04e16..8c32f8b21 100644 --- a/workers/webhook/pkg/webhook/sender_test.go +++ b/workers/webhook/pkg/webhook/sender_test.go @@ -16,239 +16,218 @@ package webhook import ( "context" - "encoding/json" - "io" + "errors" "net/http" "strings" "testing" - "time" + "github.com/GoogleChrome/webstatus.dev/lib/event" "github.com/GoogleChrome/webstatus.dev/lib/workertypes" ) -type mockHTTPClient struct { - doFunc func(req *http.Request) (*http.Response, error) -} +func TestSender_SendWebhook_Success(t *testing.T) { + mockHTTP := &mockHTTPClient{ + doFunc: func(_ *http.Request) (*http.Response, error) { + return newTestResponse(http.StatusOK, "ok"), nil + }, + } -func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { - return m.doFunc(req) -} + mockState := &mockChannelStateManager{ + successCalls: nil, + failureCalls: nil, + recordErr: nil, + } + sender := NewSender(mockHTTP, mockState, "https://webstatus.dev") -type mockChannelStateManager struct { - successCalls []successCall - failureCalls []failureCall -} + job := newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/123", workertypes.WebhookTypeSlack, "group:css", []byte(`{"text":"test"}`)) -type successCall struct { - channelID string - timestamp time.Time - eventID string -} + err := sender.SendWebhook(context.Background(), job) + if err != nil { + t.Fatalf("SendWebhook failed: %v", err) + } -type failureCall struct { - channelID string - err error - timestamp time.Time - isPermanent bool - eventID string + verifySuccess(t, mockState) } -func (m *mockChannelStateManager) RecordSuccess(_ context.Context, channelID string, - timestamp time.Time, eventID string) error { - m.successCalls = append(m.successCalls, successCall{channelID, timestamp, eventID}) +func TestSender_SendWebhook_TransientFailure(t *testing.T) { + mockHTTP := &mockHTTPClient{ + doFunc: func(_ *http.Request) (*http.Response, error) { + return nil, event.ErrTransientFailure + }, + } + mockState := &mockChannelStateManager{ + successCalls: nil, + failureCalls: nil, + recordErr: nil, + } + sender := NewSender(mockHTTP, mockState, "https://webstatus.dev") - return nil -} + job := newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/123", workertypes.WebhookTypeSlack, "group:css", []byte(`{"text":"test"}`)) -func (m *mockChannelStateManager) RecordFailure(_ context.Context, channelID string, - err error, timestamp time.Time, isPermanent bool, eventID string) error { - m.failureCalls = append(m.failureCalls, failureCall{channelID, err, timestamp, isPermanent, eventID}) + err := sender.SendWebhook(context.Background(), job) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, event.ErrTransientFailure) { + t.Errorf("expected transient failure error, got %v", err) + } - return nil + if len(mockState.failureCalls) != 1 { + t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls)) + } + if mockState.failureCalls[0].isPermanent { + t.Error("expected transient failure recorded") + } } -func TestSender_SendWebhook_Success(t *testing.T) { +func TestSender_SendWebhook_HTTPFailure(t *testing.T) { mockHTTP := &mockHTTPClient{ - doFunc: func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "https://hooks.slack.com/services/123" { - t.Errorf("unexpected URL: %s", req.URL.String()) - } - if req.Method != http.MethodPost { - t.Errorf("unexpected method: %s", req.Method) - } - body, _ := io.ReadAll(req.Body) - var payload SlackPayload - if err := json.Unmarshal(body, &payload); err != nil { - t.Errorf("failed to unmarshal payload: %v", err) - } - if !strings.Contains(payload.Text, "Test Body") { - t.Errorf("payload does not contain expected text: %s", payload.Text) - } - expectedLink := "View Results: https://webstatus.dev/features?q=group%3Acss" - if !strings.Contains(payload.Text, expectedLink) { - t.Errorf("payload missing expected link. Got: %s", payload.Text) - } - - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader("ok")), - Status: "200 OK", - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: 2, - TransferEncoding: nil, - Close: false, - Uncompressed: false, - Trailer: nil, - Request: nil, - TLS: nil, - }, nil + doFunc: func(_ *http.Request) (*http.Response, error) { + return nil, errors.New("network error") }, } - mockState := &mockChannelStateManager{ successCalls: nil, failureCalls: nil, + recordErr: nil, } - frontendBaseURL := "https://webstatus.dev" - sender := NewSender(mockHTTP, mockState, frontendBaseURL) - - summary := workertypes.EventSummary{ - Text: "Test Body", - SchemaVersion: "v1", - Categories: workertypes.SummaryCategories{ - QueryChanged: 0, - Added: 0, - Removed: 0, - Deleted: 0, - Moved: 0, - Split: 0, - Updated: 0, - UpdatedImpl: 0, - UpdatedRename: 0, - UpdatedBaseline: 0, - }, - Truncated: false, - Highlights: nil, - } - summaryRaw, _ := json.Marshal(summary) - - job := workertypes.IncomingWebhookDeliveryJob{ - WebhookDeliveryJob: workertypes.WebhookDeliveryJob{ - ChannelID: "chan-1", - WebhookURL: "https://hooks.slack.com/services/123", - WebhookType: workertypes.WebhookTypeSlack, - SummaryRaw: summaryRaw, - SubscriptionID: "sub-1", - Triggers: nil, - Metadata: workertypes.DeliveryMetadata{ - EventID: "evt-1", - SearchID: "search-1", - SearchName: "", - Query: "group:css", - Frequency: workertypes.FrequencyImmediate, - GeneratedAt: time.Time{}, - }, - }, - WebhookEventID: "evt-1", - } + sender := NewSender(mockHTTP, mockState, "https://webstatus.dev") + + job := newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/123", workertypes.WebhookTypeSlack, "group:css", []byte(`{"text":"test"}`)) err := sender.SendWebhook(context.Background(), job) - if err != nil { - t.Fatalf("SendWebhook failed: %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - // Verify the payload text contains the link - // The mock doFunc already checked basic text, but let's verify exact format if needed. - // (Actual check is inside the mock doFunc redefined above if we want to be strict) + if !errors.Is(err, event.ErrTransientFailure) { + t.Errorf("expected transient failure error, got %v", err) + } - if len(mockState.successCalls) != 1 { - t.Errorf("expected 1 success call, got %d", len(mockState.successCalls)) + if len(mockState.failureCalls) != 1 { + t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls)) } - if mockState.successCalls[0].channelID != "chan-1" { - t.Errorf("unexpected channel ID: %s", mockState.successCalls[0].channelID) + if mockState.failureCalls[0].isPermanent { + t.Error("expected transient failure recorded") } } -func TestSender_SendWebhook_HTTPFailure(t *testing.T) { +func TestSender_SendWebhook_PermanentFailure(t *testing.T) { mockHTTP := &mockHTTPClient{ doFunc: func(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusNotFound, - Body: io.NopCloser(strings.NewReader("not found")), - Status: "404 Not Found", - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - ContentLength: 9, - TransferEncoding: nil, - Close: false, - Uncompressed: false, - Trailer: nil, - Request: nil, - TLS: nil, - }, nil + return newTestResponse(http.StatusNotFound, "not found"), nil }, } - mockState := &mockChannelStateManager{ successCalls: nil, failureCalls: nil, + recordErr: nil, } sender := NewSender(mockHTTP, mockState, "https://webstatus.dev") - summary := workertypes.EventSummary{ - Text: "Test Body", - SchemaVersion: "v1", - Categories: workertypes.SummaryCategories{ - QueryChanged: 0, - Added: 0, - Removed: 0, - Deleted: 0, - Moved: 0, - Split: 0, - Updated: 0, - UpdatedImpl: 0, - UpdatedRename: 0, - UpdatedBaseline: 0, - }, - Truncated: false, - Highlights: nil, - } - summaryRaw, _ := json.Marshal(summary) - - job := workertypes.IncomingWebhookDeliveryJob{ - WebhookDeliveryJob: workertypes.WebhookDeliveryJob{ - ChannelID: "chan-1", - WebhookURL: "https://hooks.slack.com/services/123", - WebhookType: workertypes.WebhookTypeSlack, - SummaryRaw: summaryRaw, - SubscriptionID: "sub-1", - Triggers: nil, - Metadata: workertypes.DeliveryMetadata{ - EventID: "evt-1", - SearchID: "search-1", - SearchName: "", - Query: "group:css", - Frequency: workertypes.FrequencyImmediate, - GeneratedAt: time.Time{}, - }, - }, - WebhookEventID: "evt-1", + job := newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/123", workertypes.WebhookTypeSlack, "group:css", []byte(`{"text":"test"}`)) + + err := sender.SendWebhook(context.Background(), job) + if err == nil { + t.Fatal("expected error, got nil") + } + + if errors.Is(err, event.ErrTransientFailure) { + t.Error("did not expect transient failure error") + } + + if len(mockState.failureCalls) != 1 { + t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls)) + } + if !mockState.failureCalls[0].isPermanent { + t.Error("expected permanent failure recorded") + } +} + +func TestSender_SendWebhook_UnsupportedType(t *testing.T) { + mockState := &mockChannelStateManager{ + successCalls: nil, + failureCalls: nil, + recordErr: nil, } + sender := NewSender(nil, mockState, "https://webstatus.dev") + + job := newTestIncomingWebhookDeliveryJob( + "https://example.com/webhook", "unknown", "group:css", nil) err := sender.SendWebhook(context.Background(), job) if err == nil { t.Fatal("expected error, got nil") } + if !strings.Contains(err.Error(), "unsupported type") { + t.Errorf("unexpected error message: %v", err) + } if len(mockState.failureCalls) != 1 { t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls)) } if !mockState.failureCalls[0].isPermanent { - t.Error("expected permanent failure for 404") + t.Error("expected permanent failure for unsupported type") + } +} + +func TestSender_SendWebhook_InvalidSlackURL(t *testing.T) { + mockState := &mockChannelStateManager{ + successCalls: nil, + failureCalls: nil, + recordErr: nil, + } + sender := NewSender(nil, mockState, "https://webstatus.dev") + + job := newTestIncomingWebhookDeliveryJob( + "https://not-slack.com/hook", workertypes.WebhookTypeSlack, "group:css", nil) + + err := sender.SendWebhook(context.Background(), job) + if err == nil { + t.Fatal("expected error, got nil") + } + + if len(mockState.failureCalls) != 1 { + t.Errorf("expected 1 failure call, got %d", len(mockState.failureCalls)) + } + if !mockState.failureCalls[0].isPermanent { + t.Error("expected permanent failure for invalid URL") + } +} + +func TestSender_SendWebhook_InvalidSummary(t *testing.T) { + mockState := &mockChannelStateManager{ + successCalls: nil, + failureCalls: nil, + recordErr: nil, + } + sender := NewSender(nil, mockState, "https://webstatus.dev") + + job := newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/123", workertypes.WebhookTypeSlack, "group:css", nil) + job.SummaryRaw = []byte(`invalid json`) + + err := sender.SendWebhook(context.Background(), job) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to unmarshal summary") { + t.Errorf("unexpected error message: %v", err) + } +} + +func verifySuccess(t *testing.T, mockState *mockChannelStateManager) { + t.Helper() + if len(mockState.successCalls) != 1 { + t.Errorf("expected 1 success call, got %d", len(mockState.successCalls)) + } else if mockState.successCalls[0].channelID != "chan-1" { + t.Errorf("unexpected channel ID: %s", mockState.successCalls[0].channelID) + } else if mockState.successCalls[0].eventID != "evt-123" { + t.Errorf("unexpected event ID: %s", mockState.successCalls[0].eventID) } } diff --git a/workers/webhook/pkg/webhook/slack.go b/workers/webhook/pkg/webhook/slack.go new file mode 100644 index 000000000..34e785b25 --- /dev/null +++ b/workers/webhook/pkg/webhook/slack.go @@ -0,0 +1,98 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/GoogleChrome/webstatus.dev/lib/httputils" + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" +) + +type SlackPayload struct { + Text string `json:"text"` +} + +type slackSender struct { + frontendBaseURL string + httpClient HTTPClient + job workertypes.IncomingWebhookDeliveryJob +} + +func newSlackSender(frontendBaseURL string, httpClient HTTPClient, + job workertypes.IncomingWebhookDeliveryJob) (*slackSender, error) { + if err := httputils.ValidateSlackWebhookURL(job.WebhookURL); err != nil { + return nil, fmt.Errorf("%w: invalid webhook URL: %w", ErrPermanentWebhook, err) + } + + return &slackSender{ + frontendBaseURL: frontendBaseURL, + httpClient: httpClient, + job: job, + }, nil +} + +func (s *slackSender) Send(ctx context.Context) error { + var summary workertypes.EventSummary + if err := json.Unmarshal(s.job.SummaryRaw, &summary); err != nil { + return fmt.Errorf("%w: failed to unmarshal summary: %w", ErrPermanentWebhook, err) + } + + query := s.job.Metadata.Query + // Default search results page + resultsURL := fmt.Sprintf("%s/features?q=%s", s.frontendBaseURL, url.QueryEscape(query)) + + payload := SlackPayload{ + Text: fmt.Sprintf("WebStatus.dev Notification: %s\nQuery: %s\nView Results: %s", + summary.Text, query, resultsURL), + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("%w: failed to marshal slack payload: %w", ErrPermanentWebhook, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.job.WebhookURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("%w: failed to create request: %w", ErrPermanentWebhook, err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return errors.Join(ErrTransientWebhook, fmt.Errorf("network error: %w", err)) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + webhookErr := fmt.Errorf("webhook returned status code %d", resp.StatusCode) + isPermanent := resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone || + resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden + + if !isPermanent { + return errors.Join(ErrTransientWebhook, webhookErr) + } + + return errors.Join(ErrPermanentWebhook, webhookErr) +} diff --git a/workers/webhook/pkg/webhook/slack_test.go b/workers/webhook/pkg/webhook/slack_test.go new file mode 100644 index 000000000..51df83b25 --- /dev/null +++ b/workers/webhook/pkg/webhook/slack_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + + "github.com/GoogleChrome/webstatus.dev/lib/workertypes" + "github.com/google/go-cmp/cmp" +) + +func TestSlackSender_Send(t *testing.T) { + tests := []slackTestCase{ + { + name: "successful send with correct query-based payload", + job: newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", + workertypes.WebhookTypeSlack, + "group:css", + []byte(`{"text":"New feature landed"}`), + ), + mockResponse: newTestResponse(http.StatusOK, "ok"), + mockErr: nil, + expectedPayload: &SlackPayload{ + Text: "WebStatus.dev Notification: New feature landed\n" + + "Query: group:css\n" + + "View Results: https://webstatus.dev/features?q=group%3Acss", + }, + expectedErr: nil, + }, + { + name: "successful send with direct feature link", + job: newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", + workertypes.WebhookTypeSlack, + "id:\"anchor-positioning\"", + []byte(`{"text":"Test Body"}`), + ), + mockResponse: newTestResponse(http.StatusOK, "ok"), + mockErr: nil, + expectedPayload: &SlackPayload{ + Text: "WebStatus.dev Notification: Test Body\n" + + "Query: id:\"anchor-positioning\"\n" + + "View Results: https://webstatus.dev/features?q=id%3A%22anchor-positioning%22", + }, + expectedErr: nil, + }, + { + name: "network error", + job: newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", + workertypes.WebhookTypeSlack, + "", + []byte(`{"text":"retry"}`), + ), + mockResponse: nil, + mockErr: errors.New("network failure"), + expectedPayload: nil, + expectedErr: ErrTransientWebhook, + }, + { + name: "permanent error (404)", + job: newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", + workertypes.WebhookTypeSlack, + "", + []byte(`{"text":"fail"}`), + ), + mockResponse: newTestResponse(http.StatusNotFound, "not found"), + mockErr: nil, + expectedPayload: &SlackPayload{ + Text: "WebStatus.dev Notification: fail\n" + + "Query: \n" + + "View Results: https://webstatus.dev/features?q=", + }, + expectedErr: ErrPermanentWebhook, + }, + { + name: "transient error (500)", + job: newTestIncomingWebhookDeliveryJob( + "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", + workertypes.WebhookTypeSlack, + "", + []byte(`{"text":"retry"}`), + ), + mockResponse: newTestResponse(http.StatusInternalServerError, "internal error"), + mockErr: nil, + expectedPayload: &SlackPayload{ + Text: "WebStatus.dev Notification: retry\n" + + "Query: \n" + + "View Results: https://webstatus.dev/features?q=", + }, + expectedErr: ErrTransientWebhook, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capturedBody := tt.runTest(t) + tt.verifyPayload(t, capturedBody) + }) + } +} + +func (tc *slackTestCase) runTest(t *testing.T) []byte { + var capturedBody []byte + mockHTTP := &mockHTTPClient{ + doFunc: func(req *http.Request) (*http.Response, error) { + if tc.mockErr != nil { + return nil, tc.mockErr + } + var err error + capturedBody, err = io.ReadAll(req.Body) + + return tc.mockResponse, err + }, + } + + sender, err := newSlackSender("https://webstatus.dev", mockHTTP, tc.job) + if err != nil { + if tc.expectedErr != nil && errors.Is(err, tc.expectedErr) { + return nil + } + t.Fatalf("unexpected error creating sender: %v", err) + } + + err = sender.Send(context.Background()) + if tc.expectedErr != nil { + if !errors.Is(err, tc.expectedErr) { + t.Errorf("Send() error = %v, expectedErr %v", err, tc.expectedErr) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + + return capturedBody +} + +func (tc *slackTestCase) verifyPayload(t *testing.T, capturedBody []byte) { + var actualPayload *SlackPayload + if len(capturedBody) > 0 { + actualPayload = new(SlackPayload) + if err := json.Unmarshal(capturedBody, actualPayload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + } + + if diff := cmp.Diff(tc.expectedPayload, actualPayload); diff != "" { + t.Errorf("payload mismatch (-want +got):\n%s", diff) + } +} + +type slackTestCase struct { + name string + job workertypes.IncomingWebhookDeliveryJob + mockResponse *http.Response + mockErr error + expectedPayload *SlackPayload + expectedErr error +}