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..434377e88 100644 --- a/packages/cli/src/commands/authCommand.ts +++ b/packages/cli/src/commands/authCommand.ts @@ -111,7 +111,7 @@ export abstract class AuthCommand extends BaseCommand { config.pinEnvironment() // Constructs validate against account-specific limits and have no access to // the command instance. - Session.accountFeatures = this.#account?.features ?? [] + Session.account = this.#account } protected async validateProject ( diff --git a/packages/cli/src/constructs/__tests__/account-features.spec.ts b/packages/cli/src/constructs/__tests__/account-features.spec.ts index 5aa6375f5..e9288fc1f 100644 --- a/packages/cli/src/constructs/__tests__/account-features.spec.ts +++ b/packages/cli/src/constructs/__tests__/account-features.spec.ts @@ -4,6 +4,7 @@ import { ApiCheck, TcpMonitor, TcpRequest, UrlMonitor } from '../index.js' import { Diagnostics } from '../diagnostics.js' import { Project } from '../project.js' import { Session } from '../session.js' +import type { Account } from '../../rest/accounts.js' const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS' @@ -19,6 +20,13 @@ const newProject = () => { }) } +const withFeatures = (features: string[]): Account => ({ + id: 'acc-1', + name: 'Test Account', + runtimeId: '2025.04', + features, +}) + const validate = async (construct: { validate: (diagnostics: Diagnostics) => Promise }) => { const diagnostics = new Diagnostics() await construct.validate(diagnostics) @@ -27,12 +35,12 @@ const validate = async (construct: { validate: (diagnostics: Diagnostics) => Pro describe('account-aware response time limits', () => { afterEach(() => { - Session.accountFeatures = [] + Session.account = undefined }) it('rejects an extended response time without the feature', async () => { newProject() - Session.accountFeatures = [] + Session.account = withFeatures([]) const diagnostics = await validate(new TcpMonitor('tcp-standard', { name: 'Test Check', @@ -50,7 +58,7 @@ describe('account-aware response time limits', () => { it('accepts an extended response time with the feature', async () => { newProject() - Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS]) const diagnostics = await validate(new TcpMonitor('tcp-extended', { name: 'Test Check', @@ -64,7 +72,7 @@ describe('account-aware response time limits', () => { it('defers values above 45s to the API with the feature', async () => { newProject() - Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS]) const diagnostics = await validate(new TcpMonitor('tcp-over-extended', { name: 'Test Check', @@ -77,7 +85,7 @@ describe('account-aware response time limits', () => { it('keeps the degraded <= max cross-check with the feature', async () => { newProject() - Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS]) const diagnostics = await validate(new TcpMonitor('tcp-degraded-above-max', { name: 'Test Check', @@ -96,7 +104,7 @@ describe('account-aware response time limits', () => { it('raises the API check limit above its 30s standard', async () => { newProject() - Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS]) const diagnostics = await validate(new ApiCheck('api-extended', { name: 'Test Check', @@ -109,7 +117,7 @@ describe('account-aware response time limits', () => { it('raises the URL monitor limit above its 30s standard', async () => { newProject() - Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + Session.account = withFeatures([EXTENDED_RESPONSE_TIME_LIMITS]) const diagnostics = await validate(new UrlMonitor('url-extended', { name: 'Test Check', 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..e86fa714b --- /dev/null +++ b/packages/cli/src/constructs/__tests__/slack-app-alert-channel.spec.ts @@ -0,0 +1,84 @@ +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' +import type { Account, AccountIntegration } from '../../rest/accounts.js' + +const withIntegrations = (integrations?: AccountIntegration[]): Account => ({ + id: 'acc-1', + name: 'Test Account', + runtimeId: '2025.04', + integrations, +}) + +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', + }) + }) + + afterEach(() => { + Session.account = undefined + }) + + it('refuses the channel when the account has no Checkly Slack app', async () => { + Session.account = withIntegrations([{ type: 'SLACK', connected: false }]) + + 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.account = withIntegrations([{ type: 'SLACK', connected: true }]) + + const diagnostics = await validate(['#ops']) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('skips the check when the API does not report integrations', async () => { + Session.account = withIntegrations(undefined) + + const diagnostics = await validate(['#ops']) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('skips the check when the API does not report the Slack app', async () => { + Session.account = withIntegrations([{ type: 'OTHER', connected: false }]) + + const diagnostics = await validate(['#ops']) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('accepts a channel without Slack destinations, which needs no Slack app', async () => { + Session.account = withIntegrations([{ type: 'SLACK', connected: false }]) + + const diagnostics = await validate([]) + + expect(diagnostics.isFatal()).toBe(false) + }) +}) diff --git a/packages/cli/src/constructs/internal/account-features.ts b/packages/cli/src/constructs/internal/account-features.ts index 03961b671..7f2dd9a4b 100644 --- a/packages/cli/src/constructs/internal/account-features.ts +++ b/packages/cli/src/constructs/internal/account-features.ts @@ -16,7 +16,7 @@ const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS' * the API does not report features at all (older or self-hosted deployments). */ export function responseTimeLimits (standard: number): ResponseTimeLimits { - const limit = Session.accountFeatures.includes(EXTENDED_RESPONSE_TIME_LIMITS) + const limit = Session.account?.features?.includes(EXTENDED_RESPONSE_TIME_LIMITS) ? Number.MAX_SAFE_INTEGER : standard diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index a918d9d65..764719c27 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -6,6 +6,7 @@ import { type EngineDetectionResult } from '../services/engine-detector.js' import { Parser } from '../services/check-parser/parser.js' import { Construct } from './construct.js' import { PrivateLocationApi } from '../rest/private-locations.js' +import type { Account } from '../rest/accounts.js' import { FileLoader, JitiFileLoader, @@ -89,7 +90,11 @@ export class Session { static verifyRuntimeDependencies = true static loadingChecklyConfigFile: boolean static checklyConfigFileConstructs?: Construct[] - static accountFeatures: string[] = [] + /** + * The authenticated account, for constructs that validate against it. + * Undefined when the command does not authenticate. + */ + static account?: Account static privateLocations: PrivateLocationApi[] static parsers = new Map() static playwrightProjectBundler?: PlaywrightProjectBundler @@ -123,7 +128,7 @@ export class Session { this.verifyRuntimeDependencies = true this.loadingChecklyConfigFile = false this.checklyConfigFileConstructs = undefined - this.accountFeatures = [] + this.account = 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..60451b5fa 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 API does not report the Slack app. + */ + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + const slack = Session.account?.integrations?.find(({ type }) => type === SLACK_INTEGRATION) + if (slack === undefined || slack.connected || !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.account?.id}/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..c3a180469 100644 --- a/packages/cli/src/rest/accounts.ts +++ b/packages/cli/src/rest/accounts.ts @@ -17,6 +17,17 @@ export interface Account { * in which case standard limits apply. */ features?: string[] + /** + * Every integration type the API reports, each marked connected or not, e.g. + * `SLACK` for the Checkly Slack app. A type missing from the list is one the + * API does not report. Absent when the API predates it. + */ + integrations?: AccountIntegration[] +} + +export interface AccountIntegration { + type: string + connected: boolean } class Accounts {