diff --git a/examples/advanced-project-js/src/__checks__/utils/alert-channels.js b/examples/advanced-project-js/src/__checks__/utils/alert-channels.js index 251cfdabe..4002a88ae 100644 --- a/examples/advanced-project-js/src/__checks__/utils/alert-channels.js +++ b/examples/advanced-project-js/src/__checks__/utils/alert-channels.js @@ -2,7 +2,6 @@ const { URL } = require('node:url') const { EmailAlertChannel, - SlackAppAlertChannel, WebhookAlertChannel, } = require('checkly/constructs') @@ -25,10 +24,13 @@ const emailChannel = new EmailAlertChannel('email-channel-1', { ...sendDefaults, }) -const slackChannel = new SlackAppAlertChannel('slack-channel-1', { - slackChannels: ['#ops'], - ...sendDefaults, -}) +// A Slack channel needs the Checkly Slack app connected to your account first +// (https://www.checklyhq.com/docs/integrations/alerts/slack/). Once it is, import +// SlackAppAlertChannel from 'checkly/constructs' and uncomment: +// const slackChannel = new SlackAppAlertChannel('slack-channel-1', { +// slackChannels: ['#ops'], +// ...sendDefaults, +// }) const webhookChannel = new WebhookAlertChannel('webhook-channel-1', { name: 'Pushover webhook', @@ -49,6 +51,5 @@ const webhookChannel = new WebhookAlertChannel('webhook-channel-1', { module.exports = { emailChannel, - slackChannel, webhookChannel, } diff --git a/examples/advanced-project/src/__checks__/utils/alert-channels.ts b/examples/advanced-project/src/__checks__/utils/alert-channels.ts index 730f41733..d33cd06a5 100644 --- a/examples/advanced-project/src/__checks__/utils/alert-channels.ts +++ b/examples/advanced-project/src/__checks__/utils/alert-channels.ts @@ -1,7 +1,6 @@ import { URL } from 'node:url' import { EmailAlertChannel, - SlackAppAlertChannel, WebhookAlertChannel } from 'checkly/constructs' @@ -25,10 +24,13 @@ export const emailChannel = new EmailAlertChannel('email-channel-1', { ...sendDefaults }) -export const slackChannel = new SlackAppAlertChannel('slack-channel-1', { - slackChannels: ['#ops'], - ...sendDefaults -}) +// A Slack channel needs the Checkly Slack app connected to your account first +// (https://www.checklyhq.com/docs/integrations/alerts/slack/). Once it is, import +// SlackAppAlertChannel from 'checkly/constructs' and uncomment: +// export const slackChannel = new SlackAppAlertChannel('slack-channel-1', { +// slackChannels: ['#ops'], +// ...sendDefaults +// }) export const webhookChannel = new WebhookAlertChannel('webhook-channel-1', { name: 'Pushover webhook', diff --git a/packages/cli/src/ai-context/references/configure-alert-channels.md b/packages/cli/src/ai-context/references/configure-alert-channels.md index 287c0efb7..cc04c2c43 100644 --- a/packages/cli/src/ai-context/references/configure-alert-channels.md +++ b/packages/cli/src/ai-context/references/configure-alert-channels.md @@ -9,7 +9,7 @@ All available alerts are described in the [Checkly docs](https://www.checklyhq.c *Important*: Don't make up email addresses, phone numbers, Slack channel names or similar static values. Scan the project to discover a valid configuration or ask what the values should be. -For new Slack notifications, prefer `SlackAppAlertChannel`. It uses the Checkly Slack App and only needs Slack `#channel` names or `@user` handles in `slackChannels`. +For new Slack notifications, prefer `SlackAppAlertChannel`. It uses the Checkly Slack App and only needs Slack `#channel` names or `@user` handles in `slackChannels`. It needs the Checkly Slack App connected to the Checkly account: without it, `npx checkly validate`, `test` and `deploy` fail with "Checkly Slack app not connected". If you can't confirm the app is connected, ask before adding one. ## Email Alert Channel diff --git a/packages/cli/src/commands/authCommand.ts b/packages/cli/src/commands/authCommand.ts index 123c7fa06..4a93e4b2f 100644 --- a/packages/cli/src/commands/authCommand.ts +++ b/packages/cli/src/commands/authCommand.ts @@ -112,6 +112,8 @@ export abstract class AuthCommand extends BaseCommand { // Constructs validate against account-specific limits and have no access to // the command instance. Session.accountFeatures = this.#account?.features ?? [] + Session.accountId = this.#account?.id + Session.accountConnectedIntegrations = this.#account?.connectedIntegrations } protected async validateProject ( diff --git a/packages/cli/src/constructs/__tests__/slack-app-alert-channel.spec.ts b/packages/cli/src/constructs/__tests__/slack-app-alert-channel.spec.ts new file mode 100644 index 000000000..ae59d5857 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/slack-app-alert-channel.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, afterEach, beforeEach } from 'vitest' + +import { SlackAppAlertChannel } from '../index.js' +import { Diagnostics } from '../diagnostics.js' +import { Project } from '../project.js' +import { Session } from '../session.js' + +const validate = async (slackChannels: string[]) => { + const diagnostics = new Diagnostics() + await new SlackAppAlertChannel('ops-slack', { slackChannels }).validate(diagnostics) + return diagnostics +} + +describe('SlackAppAlertChannel', () => { + beforeEach(() => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + Session.accountId = 'acc-1' + }) + + afterEach(() => { + Session.accountId = undefined + Session.accountConnectedIntegrations = undefined + }) + + it('refuses the channel when the account has no Checkly Slack app', async () => { + Session.accountConnectedIntegrations = [] + + const diagnostics = await validate(['#ops']) + + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual([ + expect.objectContaining({ + title: 'Checkly Slack app not connected', + message: expect.stringContaining( + `SlackAppAlertChannel 'ops-slack' needs the Checkly Slack app, which is not connected to this account, so its alerts cannot be delivered.`, + ), + }), + ]) + expect(diagnostics.observations[0].message).toContain( + 'https://app.checklyhq.com/accounts/acc-1/settings/account/integrations', + ) + }) + + it('accepts the channel when the Checkly Slack app is connected', async () => { + Session.accountConnectedIntegrations = ['SLACK'] + + const diagnostics = await validate(['#ops']) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('skips the check when the API does not report connected integrations', async () => { + Session.accountConnectedIntegrations = undefined + + const diagnostics = await validate(['#ops']) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('accepts a channel without Slack destinations, which needs no Slack app', async () => { + Session.accountConnectedIntegrations = [] + + const diagnostics = await validate([]) + + expect(diagnostics.isFatal()).toBe(false) + }) +}) diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index a918d9d65..113152e02 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -90,6 +90,12 @@ export class Session { static loadingChecklyConfigFile: boolean static checklyConfigFileConstructs?: Construct[] static accountFeatures: string[] = [] + static accountId?: string + /** + * The integrations the authenticated account has connected, or undefined + * when unknown (not authenticated, or an API that does not report them). + */ + static accountConnectedIntegrations?: string[] static privateLocations: PrivateLocationApi[] static parsers = new Map() static playwrightProjectBundler?: PlaywrightProjectBundler @@ -124,6 +130,8 @@ export class Session { this.loadingChecklyConfigFile = false this.checklyConfigFileConstructs = undefined this.accountFeatures = [] + this.accountId = undefined + this.accountConnectedIntegrations = undefined this.privateLocations = [] this.parsers = new Map() this.playwrightProjectBundler = undefined diff --git a/packages/cli/src/constructs/slack-app-alert-channel.ts b/packages/cli/src/constructs/slack-app-alert-channel.ts index bcd9240d9..dd10554c0 100644 --- a/packages/cli/src/constructs/slack-app-alert-channel.ts +++ b/packages/cli/src/constructs/slack-app-alert-channel.ts @@ -1,6 +1,9 @@ import { AlertChannel, AlertChannelProps } from './alert-channel.js' +import { Diagnostics, ErrorDiagnostic } from './diagnostics.js' import { Session } from './session.js' +const SLACK_INTEGRATION = 'SLACK' + export interface SlackAppAlertChannelProps extends AlertChannelProps { /** * List of Slack #channels or @handles to notify. @@ -38,6 +41,33 @@ export class SlackAppAlertChannel extends AlertChannel { return `SlackAppAlertChannel:${this.logicalId}` } + /** + * Refuses the channel when the account has no Checkly Slack app: its alerts + * cannot be delivered, whether the channel is new or was deployed before the + * app was disconnected. Skipped when the account's integrations are unknown. + */ + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + const connected = Session.accountConnectedIntegrations + if (connected === undefined || connected.includes(SLACK_INTEGRATION) || !this.slackChannels?.length) { + return + } + const error = new Error( + `SlackAppAlertChannel '${this.logicalId}' needs the Checkly Slack app, which is not connected to this account,` + + ` so its alerts cannot be delivered.`, + ) + diagnostics.add(new ErrorDiagnostic({ + title: 'Checkly Slack app not connected', + message: + `${error.message}\n\n` + + `Hint: Connect the Checkly Slack app at` + + ` https://app.checklyhq.com/accounts/${Session.accountId}/settings/account/integrations,` + + ` or remove the channel.`, + error, + })) + } + synthesize () { return { ...super.synthesize(), diff --git a/packages/cli/src/rest/accounts.ts b/packages/cli/src/rest/accounts.ts index 13326d1ef..3e2431894 100644 --- a/packages/cli/src/rest/accounts.ts +++ b/packages/cli/src/rest/accounts.ts @@ -17,6 +17,11 @@ export interface Account { * in which case standard limits apply. */ features?: string[] + /** + * The integrations the account has connected, e.g. `SLACK` for the Checkly + * Slack app. Absent when the API predates it. + */ + connectedIntegrations?: string[] } class Accounts {