diff --git a/packages/cli/src/constructs/__tests__/api-check.spec.ts b/packages/cli/src/constructs/__tests__/api-check.spec.ts index d52762e69..fd6b258f6 100644 --- a/packages/cli/src/constructs/__tests__/api-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/api-check.spec.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterAll } from 'vitest' import { ApiCheck, CheckGroup, Request } from '../index' import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' const runtimes = { '2022.10': { name: '2022.10', default: false, stage: 'CURRENT', description: 'Main updates are Playwright 1.28.0, Node.js 16.x and Typescript support. We are also dropping support for Puppeteer', dependencies: { '@playwright/test': '1.28.0', '@opentelemetry/api': '1.0.4', '@opentelemetry/sdk-trace-base': '1.0.1', '@faker-js/faker': '5.5.3', aws4: '1.11.0', axios: '0.27.2', btoa: '1.2.1', chai: '4.3.7', 'chai-string': '1.5.0', 'crypto-js': '4.1.1', expect: '29.3.1', 'form-data': '4.0.0', jsonwebtoken: '8.5.1', lodash: '4.17.21', mocha: '10.1.0', moment: '2.29.2', node: '16.x', otpauth: '9.0.2', playwright: '1.28.0', typescript: '4.8.4', uuid: '9.0.0' } }, @@ -211,4 +212,126 @@ describe('ApiCheck', () => { expect(payload.retryStrategy?.onlyOn).toEqual('NETWORK_ERROR') }) }) + + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate URL length', async () => { + const longUrl = 'https://example.com/' + 'a'.repeat(2030) + const apiCheck = new ApiCheck('test-api', { + name: 'Test API', + request: { + url: longUrl, + method: 'GET' + } + }) + + const diagnostics = new Diagnostics() + await apiCheck.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('URL length must not exceed 2048 characters') + }) + ]) + ) + }) + + it('should validate HTTP method', async () => { + const apiCheck = new ApiCheck('test-api', { + name: 'Test API', + request: { + url: 'https://example.com', + method: 'INVALID' as any + } + }) + + const diagnostics = new Diagnostics() + await apiCheck.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid HTTP method') + }) + ]) + ) + }) + + it('should validate response times', async () => { + const apiCheck = new ApiCheck('test-api', { + name: 'Test API', + request: { + url: 'https://example.com', + method: 'GET' + }, + degradedResponseTime: -100, + maxResponseTime: 40000 + }) + + const diagnostics = new Diagnostics() + await apiCheck.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be 0 or greater') + }), + expect.objectContaining({ + message: expect.stringContaining('must be 30000 or lower') + }) + ]) + ) + }) + + it('should validate IP family', async () => { + const apiCheck = new ApiCheck('test-api', { + name: 'Test API', + request: { + url: 'https://example.com', + method: 'GET', + ipFamily: 'IPv5' as any + } + }) + + const diagnostics = new Diagnostics() + await apiCheck.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid IP family') + }) + ]) + ) + }) + + it('should validate body type', async () => { + const apiCheck = new ApiCheck('test-api', { + name: 'Test API', + request: { + url: 'https://example.com', + method: 'POST', + bodyType: 'INVALID' as any + } + }) + + const diagnostics = new Diagnostics() + await apiCheck.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid body type') + }) + ]) + ) + }) + }) }) diff --git a/packages/cli/src/constructs/__tests__/browser-check.spec.ts b/packages/cli/src/constructs/__tests__/browser-check.spec.ts index a42925ef9..f39cc29b6 100644 --- a/packages/cli/src/constructs/__tests__/browser-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/browser-check.spec.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterAll } from 'vitest' import { BrowserCheck, CheckGroup } from '../index' import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' const runtimes = { '2022.10': { name: '2022.10', default: false, stage: 'CURRENT', description: 'Main updates are Playwright 1.28.0, Node.js 16.x and Typescript support. We are also dropping support for Puppeteer', dependencies: { '@playwright/test': '1.28.0', '@opentelemetry/api': '1.0.4', '@opentelemetry/sdk-trace-base': '1.0.1', '@faker-js/faker': '5.5.3', aws4: '1.11.0', axios: '0.27.2', btoa: '1.2.1', chai: '4.3.7', 'chai-string': '1.5.0', 'crypto-js': '4.1.1', expect: '29.3.1', 'form-data': '4.0.0', jsonwebtoken: '8.5.1', lodash: '4.17.21', mocha: '10.1.0', moment: '2.29.2', node: '16.x', otpauth: '9.0.2', playwright: '1.28.0', typescript: '4.8.4', uuid: '9.0.0' } }, @@ -288,4 +289,73 @@ describe('BrowserCheck', () => { expect(browserCheck).toMatchObject(custom) }) }) + + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate SSL check domain format', async () => { + const browserCheck = new BrowserCheck('test-browser', { + name: 'Test Browser', + code: { content: 'test' }, + sslCheckDomain: 'invalid domain' + }) + + const diagnostics = new Diagnostics() + await browserCheck.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid FQDN') + }) + ]) + ) + }) + + it('should accept valid SSL check domain', async () => { + const browserCheck = new BrowserCheck('test-browser', { + name: 'Test Browser', + code: { content: 'test' }, + sslCheckDomain: 'app.checklyhq.com' + }) + + const diagnostics = new Diagnostics() + await browserCheck.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('should validate various invalid SSL check domains', async () => { + const invalidDomains = [ + 'domain with spaces.com', // Contains spaces + '', // Empty string + 'example..com', // Double dots + '-sub.example.com' // Starts with hyphen + ] + + for (const [i, domain] of invalidDomains.entries()) { + const browserCheck = new BrowserCheck(`test-browser-invalid-${i}`, { + name: 'Test Browser', + code: { content: 'test' }, + sslCheckDomain: domain + }) + + const diagnostics = new Diagnostics() + await browserCheck.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid FQDN') + }) + ]) + ) + } + }) + }) }) diff --git a/packages/cli/src/constructs/__tests__/email-alert-channel.spec.ts b/packages/cli/src/constructs/__tests__/email-alert-channel.spec.ts new file mode 100644 index 000000000..4cef3e9e2 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/email-alert-channel.spec.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { EmailAlertChannel } from '../index' +import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' + +describe('EmailAlertChannel', () => { + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate email address format', async () => { + const emailChannel = new EmailAlertChannel('test-email', { + address: 'invalid-email' + }) + + const diagnostics = new Diagnostics() + await emailChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Must contain @ symbol') + }) + ]) + ) + }) + + it('should validate various invalid email formats', async () => { + const invalidEmails = [ + 'plainaddress', + '', + 'no-at-symbol.com' + ] + + for (const [i, email] of invalidEmails.entries()) { + const emailChannel = new EmailAlertChannel(`test-email-invalid-${i}`, { + address: email + }) + + const diagnostics = new Diagnostics() + await emailChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Must contain @ symbol') + }) + ]) + ) + } + }) + + it('should accept valid email addresses', async () => { + const validEmails = [ + 'user@example.com', + 'user.name@example.com', + 'user+tag@example.co.uk', + 'user123@example-domain.org' + ] + + for (const email of validEmails) { + const emailChannel = new EmailAlertChannel(`test-email-${email.replace(/[^a-zA-Z0-9]/g, '-')}`, { + address: email + }) + + const diagnostics = new Diagnostics() + await emailChannel.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + } + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/heartbeat-monitor.spec.ts b/packages/cli/src/constructs/__tests__/heartbeat-monitor.spec.ts new file mode 100644 index 000000000..0c3177f0b --- /dev/null +++ b/packages/cli/src/constructs/__tests__/heartbeat-monitor.spec.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { HeartbeatMonitor } from '../index' +import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' + +describe('HeartbeatMonitor', () => { + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate period bounds - too low', async () => { + const heartbeat = new HeartbeatMonitor('test-heartbeat', { + name: 'Test Heartbeat', + period: 10, + periodUnit: 'seconds', + grace: 5, + graceUnit: 'seconds' + }) + + const diagnostics = new Diagnostics() + await heartbeat.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Period must be at least 30 seconds') + }) + ]) + ) + }) + + it('should validate period bounds - too high', async () => { + const heartbeat = new HeartbeatMonitor('test-heartbeat', { + name: 'Test Heartbeat', + period: 400, + periodUnit: 'days', + grace: 5, + graceUnit: 'seconds' + }) + + const diagnostics = new Diagnostics() + await heartbeat.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Period must not exceed 365 days') + }) + ]) + ) + }) + + it('should validate grace bounds - too high', async () => { + const heartbeat = new HeartbeatMonitor('test-heartbeat', { + name: 'Test Heartbeat', + period: 60, + periodUnit: 'seconds', + grace: 400, + graceUnit: 'days' + }) + + const diagnostics = new Diagnostics() + await heartbeat.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Grace period must not exceed 365 days') + }) + ]) + ) + }) + + it('should validate grace bounds - negative', async () => { + const heartbeat = new HeartbeatMonitor('test-heartbeat', { + name: 'Test Heartbeat', + period: 60, + periodUnit: 'seconds', + grace: -10, + graceUnit: 'seconds' + }) + + const diagnostics = new Diagnostics() + await heartbeat.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Grace period must be 0 or greater') + }) + ]) + ) + }) + + it('should validate time units', async () => { + const heartbeat = new HeartbeatMonitor('test-heartbeat', { + name: 'Test Heartbeat', + period: 60, + periodUnit: 'invalid' as any, + grace: 30, + graceUnit: 'also-invalid' as any + }) + + const diagnostics = new Diagnostics() + await heartbeat.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid time unit "invalid"') + }), + expect.objectContaining({ + message: expect.stringContaining('Invalid time unit "also-invalid"') + }) + ]) + ) + }) + + it('should accept valid configuration', async () => { + const heartbeat = new HeartbeatMonitor('test-heartbeat', { + name: 'Test Heartbeat', + period: 5, + periodUnit: 'minutes', + grace: 30, + graceUnit: 'seconds' + }) + + const diagnostics = new Diagnostics() + await heartbeat.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/slack-alert-channel.spec.ts b/packages/cli/src/constructs/__tests__/slack-alert-channel.spec.ts new file mode 100644 index 000000000..a7d8a9d72 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/slack-alert-channel.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { SlackAlertChannel } from '../index' +import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' + +describe('SlackAlertChannel', () => { + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate Slack webhook URL domain', async () => { + const slackChannel = new SlackAlertChannel('test-slack', { + url: 'https://example.com/webhook' + }) + + const diagnostics = new Diagnostics() + await slackChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('URL must be a valid Slack webhook URL') + }) + ]) + ) + }) + + it('should validate URL format', async () => { + const slackChannel = new SlackAlertChannel('test-slack', { + url: 'invalid-url' + }) + + const diagnostics = new Diagnostics() + await slackChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid URL format') + }) + ]) + ) + }) + + it('should validate Slack channel format', async () => { + const slackChannel = new SlackAlertChannel('test-slack', { + url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', + channel: 'invalid-channel-format' + }) + + const diagnostics = new Diagnostics() + await slackChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid Slack channel format') + }) + ]) + ) + }) + + it('should validate various invalid channel formats', async () => { + const invalidChannels = [ + 'no-prefix', + '#', + '@', + '#channel with spaces', + '@user with spaces', + '##double-hash', + '@@double-at' + ] + + for (const [i, channel] of invalidChannels.entries()) { + const slackChannel = new SlackAlertChannel(`test-slack-invalid-${i}`, { + url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', + channel + }) + + const diagnostics = new Diagnostics() + await slackChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid Slack channel format') + }) + ]) + ) + } + }) + + it('should accept valid Slack configuration', async () => { + const slackChannel = new SlackAlertChannel('test-slack', { + url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', + channel: '#alerts' + }) + + const diagnostics = new Diagnostics() + await slackChannel.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('should accept valid Slack channel formats', async () => { + const validChannels = [ + '#general', + '@user', + '#team-alerts', + '@john_doe', + '#channel_with_underscores', + '#channel-with-hyphens' + ] + + for (const channel of validChannels) { + const slackChannel = new SlackAlertChannel(`test-slack-${channel.replace(/[^a-zA-Z0-9]/g, '-')}`, { + url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', + channel + }) + + const diagnostics = new Diagnostics() + await slackChannel.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + } + }) + + it('should accept URL object type', async () => { + const slackChannel = new SlackAlertChannel('test-slack', { + url: new URL('https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX') + }) + + const diagnostics = new Diagnostics() + await slackChannel.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/sms-alert-channel.spec.ts b/packages/cli/src/constructs/__tests__/sms-alert-channel.spec.ts new file mode 100644 index 000000000..fc9a6d76b --- /dev/null +++ b/packages/cli/src/constructs/__tests__/sms-alert-channel.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { SmsAlertChannel } from '../index' +import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' + +describe('SmsAlertChannel', () => { + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate phone number format', async () => { + const smsChannel = new SmsAlertChannel('test-sms', { + phoneNumber: '1234567890' + }) + + const diagnostics = new Diagnostics() + await smsChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Must be in E.164 format') + }) + ]) + ) + }) + + it('should validate various invalid phone number formats', async () => { + const invalidPhones = [ + '1234567890', // Missing + + '+0123456789', // Starts with 0 + '+abc123456789', // Contains letters + '++1234567890', // Double + + '+1', // Too short + '+' + '1'.repeat(20) // Too long + ] + + for (const phone of invalidPhones) { + const smsChannel = new SmsAlertChannel(`test-sms-${phone.replace(/[^a-zA-Z0-9]/g, '-')}`, { + phoneNumber: phone + }) + + const diagnostics = new Diagnostics() + await smsChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Must be in E.164 format') + }) + ]) + ) + } + }) + + it('should accept valid E.164 phone numbers', async () => { + const validPhones = [ + '+1234567890', + '+441234567890', + '+33123456789', + '+81234567890' + ] + + for (const phone of validPhones) { + const smsChannel = new SmsAlertChannel(`test-sms-${phone.replace(/[^a-zA-Z0-9]/g, '-')}`, { + phoneNumber: phone + }) + + const diagnostics = new Diagnostics() + await smsChannel.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + } + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/tcp-monitor.spec.ts b/packages/cli/src/constructs/__tests__/tcp-monitor.spec.ts index ffbafeda5..cd87d88a8 100644 --- a/packages/cli/src/constructs/__tests__/tcp-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/tcp-monitor.spec.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { TcpMonitor, CheckGroup, TcpRequest } from '../index' import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' const request: TcpRequest = { hostname: 'acme.com', @@ -67,4 +68,142 @@ describe('TcpMonitor', () => { const bundle = await check.bundle() expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) }) + + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate port range', async () => { + const tcpMonitor = new TcpMonitor('test-tcp', { + name: 'Test TCP', + request: { + hostname: 'example.com', + port: 70000 + } + }) + + const diagnostics = new Diagnostics() + await tcpMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Port must be between 1 and 65535') + }) + ]) + ) + }) + + it('should validate hostname format - no scheme', async () => { + const tcpMonitor = new TcpMonitor('test-tcp', { + name: 'Test TCP', + request: { + hostname: 'https://example.com', + port: 443 + } + }) + + const diagnostics = new Diagnostics() + await tcpMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Hostname should not include a scheme') + }) + ]) + ) + }) + + it('should validate hostname format - no port', async () => { + const tcpMonitor = new TcpMonitor('test-tcp', { + name: 'Test TCP', + request: { + hostname: 'example.com:8080', + port: 443 + } + }) + + const diagnostics = new Diagnostics() + await tcpMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Hostname should not include a port number') + }) + ]) + ) + }) + + it('should validate IP family', async () => { + const tcpMonitor = new TcpMonitor('test-tcp', { + name: 'Test TCP', + request: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv5' as any + } + }) + + const diagnostics = new Diagnostics() + await tcpMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid IP family') + }) + ]) + ) + }) + + it('should validate response times', async () => { + const tcpMonitor = new TcpMonitor('test-tcp', { + name: 'Test TCP', + request: { + hostname: 'example.com', + port: 443 + }, + degradedResponseTime: -100, + maxResponseTime: 10000 + }) + + const diagnostics = new Diagnostics() + await tcpMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be 0 or greater') + }), + expect.objectContaining({ + message: expect.stringContaining('must be 5000 or lower') + }) + ]) + ) + }) + + it('should accept valid configuration', async () => { + const tcpMonitor = new TcpMonitor('test-tcp', { + name: 'Test TCP', + request: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv4' + }, + degradedResponseTime: 1000, + maxResponseTime: 3000 + }) + + const diagnostics = new Diagnostics() + await tcpMonitor.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + }) }) diff --git a/packages/cli/src/constructs/__tests__/url-monitor.spec.ts b/packages/cli/src/constructs/__tests__/url-monitor.spec.ts new file mode 100644 index 000000000..dfe6450dd --- /dev/null +++ b/packages/cli/src/constructs/__tests__/url-monitor.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { UrlMonitor } from '../index' +import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' + +describe('UrlMonitor', () => { + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate URL format', async () => { + const urlMonitor = new UrlMonitor('test-url', { + name: 'Test URL', + request: { + url: 'ftp://example.com' + } + }) + + const diagnostics = new Diagnostics() + await urlMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('URL must start with http:// or https://') + }) + ]) + ) + }) + + it('should validate URL length', async () => { + const longUrl = 'https://example.com/' + 'a'.repeat(2030) + const urlMonitor = new UrlMonitor('test-url', { + name: 'Test URL', + request: { + url: longUrl + } + }) + + const diagnostics = new Diagnostics() + await urlMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('URL length must not exceed 2048 characters') + }) + ]) + ) + }) + + it('should validate IP family', async () => { + const urlMonitor = new UrlMonitor('test-url', { + name: 'Test URL', + request: { + url: 'https://example.com', + ipFamily: 'IPv5' as any + } + }) + + const diagnostics = new Diagnostics() + await urlMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid IP family') + }) + ]) + ) + }) + + it('should validate response times', async () => { + const urlMonitor = new UrlMonitor('test-url', { + name: 'Test URL', + request: { + url: 'https://example.com' + }, + degradedResponseTime: -100, + maxResponseTime: 40000 + }) + + const diagnostics = new Diagnostics() + await urlMonitor.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be 0 or greater') + }), + expect.objectContaining({ + message: expect.stringContaining('must be 30000 or lower') + }) + ]) + ) + }) + + it('should accept valid configuration', async () => { + const urlMonitor = new UrlMonitor('test-url', { + name: 'Test URL', + request: { + url: 'https://example.com/api/health', + ipFamily: 'IPv4', + followRedirects: true, + skipSSL: false + }, + degradedResponseTime: 1000, + maxResponseTime: 5000 + }) + + const diagnostics = new Diagnostics() + await urlMonitor.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/webhook-alert-channel.spec.ts b/packages/cli/src/constructs/__tests__/webhook-alert-channel.spec.ts new file mode 100644 index 000000000..9f2c8f4c2 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/webhook-alert-channel.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { WebhookAlertChannel } from '../index' +import { Project, Session } from '../project' +import { Diagnostics } from '../diagnostics' + +describe('WebhookAlertChannel', () => { + describe('validation', () => { + beforeEach(() => { + Session.project = new Project('validation-test-project', { + name: 'Validation Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + }) + + it('should validate HTTP method', async () => { + const webhookChannel = new WebhookAlertChannel('test-webhook', { + name: 'Test Webhook', + url: 'https://example.com/webhook', + method: 'INVALID' as any + }) + + const diagnostics = new Diagnostics() + await webhookChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid HTTP method') + }) + ]) + ) + }) + + it('should validate URL format', async () => { + const webhookChannel = new WebhookAlertChannel('test-webhook', { + name: 'Test Webhook', + url: 'not a url' + }) + + const diagnostics = new Diagnostics() + await webhookChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid URL') + }) + ]) + ) + }) + + it('should validate various invalid URLs', async () => { + const invalidUrls = [ + 'just-text', + 'ftp://invalid-protocol.com', + 'http://', + 'https://', + '://missing-protocol.com' + ] + + for (const [i, url] of invalidUrls.entries()) { + const webhookChannel = new WebhookAlertChannel(`test-webhook-invalid-${i}`, { + name: 'Test Webhook', + url + }) + + const diagnostics = new Diagnostics() + await webhookChannel.validate(diagnostics) + + expect(diagnostics.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Invalid URL') + }) + ]) + ) + } + }) + + it('should accept valid webhook configuration', async () => { + const webhookChannel = new WebhookAlertChannel('test-webhook', { + name: 'Test Webhook', + url: 'https://api.example.com/webhooks/alerts', + method: 'POST', + headers: [{ key: 'Authorization', value: 'Bearer token' }], + queryParameters: [{ key: 'source', value: 'checkly' }] + }) + + const diagnostics = new Diagnostics() + await webhookChannel.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('should accept URL object type', async () => { + const webhookChannel = new WebhookAlertChannel('test-webhook', { + name: 'Test Webhook', + url: new URL('https://api.example.com/webhooks/alerts'), + method: 'PUT' + }) + + const diagnostics = new Diagnostics() + await webhookChannel.validate(diagnostics) + + expect(diagnostics.isFatal()).toBe(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/api-check.ts b/packages/cli/src/constructs/api-check.ts index 9ef4c1a7b..d98696806 100644 --- a/packages/cli/src/constructs/api-check.ts +++ b/packages/cli/src/constructs/api-check.ts @@ -242,6 +242,44 @@ export class ApiCheck extends RuntimeCheck { async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) + // Validate request properties + if (this.request) { + // Validate URL length (max 2048 characters) + if (this.request.url && this.request.url.length > 2048) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.url', + new Error(`URL length must not exceed 2048 characters. Current length: ${this.request.url.length}`), + )) + } + + // Validate HTTP method + const validMethods = ['GET', 'get', 'POST', 'post', 'PUT', 'put', 'PATCH', 'patch', 'HEAD', 'head', 'DELETE', 'delete', 'OPTIONS', 'options'] + if (this.request.method && !validMethods.includes(this.request.method)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.method', + new Error(`Invalid HTTP method "${this.request.method}". Valid methods are: ${validMethods.join(', ')}`), + )) + } + + // Validate body type + const validBodyTypes = ['JSON', 'FORM', 'RAW', 'GRAPHQL', 'NONE'] + if (this.request.bodyType && !validBodyTypes.includes(this.request.bodyType)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.bodyType', + new Error(`Invalid body type "${this.request.bodyType}". Valid types are: ${validBodyTypes.join(', ')}`), + )) + } + + // Validate IP family + const validIPFamilies = ['IPv4', 'IPv6'] + if (this.request.ipFamily && !validIPFamilies.includes(this.request.ipFamily)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.ipFamily', + new Error(`Invalid IP family "${this.request.ipFamily}". Valid values are: ${validIPFamilies.join(', ')}`), + )) + } + } + if (this.setupScript) { if (!isEntrypoint(this.setupScript) && !isContent(this.setupScript)) { diagnostics.add(new InvalidPropertyValueDiagnostic( @@ -304,6 +342,25 @@ export class ApiCheck extends RuntimeCheck { )) } + // Validate response times with proper bounds + if (this.degradedResponseTime !== undefined) { + if (this.degradedResponseTime < 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'degradedResponseTime', + new Error(`The value of "degradedResponseTime" must be 0 or greater. Current value: ${this.degradedResponseTime}`), + )) + } + } + + if (this.maxResponseTime !== undefined) { + if (this.maxResponseTime < 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'maxResponseTime', + new Error(`The value of "maxResponseTime" must be 0 or greater. Current value: ${this.maxResponseTime}`), + )) + } + } + await validateResponseTimes(diagnostics, this, { degradedResponseTime: 30_000, maxResponseTime: 30_000, diff --git a/packages/cli/src/constructs/browser-check.ts b/packages/cli/src/constructs/browser-check.ts index 3311c7528..5611e552c 100644 --- a/packages/cli/src/constructs/browser-check.ts +++ b/packages/cli/src/constructs/browser-check.ts @@ -154,6 +154,17 @@ export class BrowserCheck extends RuntimeCheck { )) } } + + // Validate sslCheckDomain is a valid FQDN + if (this.sslCheckDomain !== undefined) { + const fqdnRegex = /^(?=.{1,253}$)(?!-)(?!.*--)([\w-]{1,63}\.)+[\w-]{2,63}$/ + if (!fqdnRegex.test(this.sslCheckDomain)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'sslCheckDomain', + new Error(`Invalid FQDN "${this.sslCheckDomain}". Must be a valid fully qualified domain name (e.g., 'app.checklyhq.com').`), + )) + } + } } static async bundle (entry: string, runtimeId?: string) { diff --git a/packages/cli/src/constructs/email-alert-channel.ts b/packages/cli/src/constructs/email-alert-channel.ts index 9596724fa..7c5e42bcc 100644 --- a/packages/cli/src/constructs/email-alert-channel.ts +++ b/packages/cli/src/constructs/email-alert-channel.ts @@ -1,5 +1,7 @@ import { AlertChannel, AlertChannelProps } from './alert-channel' import { Session } from './project' +import { Diagnostics } from './diagnostics' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' export interface EmailAlertChannelProps extends AlertChannelProps { /** @@ -34,6 +36,18 @@ export class EmailAlertChannel extends AlertChannel { return `EmailAlertChannel:${this.logicalId}` } + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + // Validate email address format (simple @ check) + if (!this.address || !this.address.includes('@')) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'address', + new Error(`Invalid email address format: "${this.address}". Must contain @ symbol.`), + )) + } + } + synthesize () { return { ...super.synthesize(), diff --git a/packages/cli/src/constructs/heartbeat-monitor.ts b/packages/cli/src/constructs/heartbeat-monitor.ts index 7278366ed..d9a413cbd 100644 --- a/packages/cli/src/constructs/heartbeat-monitor.ts +++ b/packages/cli/src/constructs/heartbeat-monitor.ts @@ -2,6 +2,8 @@ import { Monitor, MonitorProps } from './monitor' import { Session } from './project' import { DateTime } from 'luxon' import CheckTypes from '../constants' +import { Diagnostics } from './diagnostics' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' type TimeUnits = 'seconds' | 'minutes' | 'hours' | 'days' @@ -31,25 +33,6 @@ export interface HeartbeatMonitorProps extends MonitorProps { graceUnit: TimeUnits } -function _customPeriodGraceValidation (heartbeat: Heartbeat) { - const now = DateTime.now() - const addedTimePeriod = now.plus({ [heartbeat.periodUnit]: heartbeat.period }) - const addedGracePeriod = now.plus({ [heartbeat.graceUnit]: heartbeat.grace }) - - const MAX_PERIOD_GRACE_DAYS = 365 - const MIN_PERIOD_SECONDS = 30 - - if ( - addedTimePeriod.diff(now, 'days').days > MAX_PERIOD_GRACE_DAYS || - addedTimePeriod.diff(now, 'seconds').seconds < MIN_PERIOD_SECONDS - ) { - throw new Error('Period must be between 30 seconds and 365 days.') - } - - if (addedGracePeriod.diff(now, 'days').days > MAX_PERIOD_GRACE_DAYS) { - throw new Error('Grace must be less than 366 days.') - } -} /** * Creates a Heartbeat Monitor @@ -67,7 +50,6 @@ export class HeartbeatMonitor extends Monitor { constructor (logicalId: string, props: HeartbeatMonitorProps) { super(logicalId, props) - _customPeriodGraceValidation(props) this.heartbeat = { period: props.period, periodUnit: props.periodUnit, @@ -83,6 +65,73 @@ export class HeartbeatMonitor extends Monitor { return `HeartbeatMonitor:${this.logicalId}` } + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + // Validate time units + const validTimeUnits = ['seconds', 'minutes', 'hours', 'days'] + let unitsValid = true + + if (!validTimeUnits.includes(this.heartbeat.periodUnit)) { + unitsValid = false + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'periodUnit', + new Error(`Invalid time unit "${this.heartbeat.periodUnit}". Valid units are: ${validTimeUnits.join(', ')}`), + )) + } + + if (!validTimeUnits.includes(this.heartbeat.graceUnit)) { + unitsValid = false + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'graceUnit', + new Error(`Invalid time unit "${this.heartbeat.graceUnit}". Valid units are: ${validTimeUnits.join(', ')}`), + )) + } + + // Only validate period and grace values if units are valid + if (unitsValid) { + const now = DateTime.now() + const addedTimePeriod = now.plus({ [this.heartbeat.periodUnit]: this.heartbeat.period }) + const addedGracePeriod = now.plus({ [this.heartbeat.graceUnit]: this.heartbeat.grace }) + + const MAX_PERIOD_GRACE_DAYS = 365 + const MIN_PERIOD_SECONDS = 30 + + const periodDiffDays = addedTimePeriod.diff(now, 'days').days + const periodDiffSeconds = addedTimePeriod.diff(now, 'seconds').seconds + + if (periodDiffDays > MAX_PERIOD_GRACE_DAYS) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'period', + new Error(`Period must not exceed 365 days. Current value: ${this.heartbeat.period} ${this.heartbeat.periodUnit} (${periodDiffDays.toFixed(2)} days)`), + )) + } + + if (periodDiffSeconds < MIN_PERIOD_SECONDS) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'period', + new Error(`Period must be at least 30 seconds. Current value: ${this.heartbeat.period} ${this.heartbeat.periodUnit} (${periodDiffSeconds} seconds)`), + )) + } + + const graceDiffDays = addedGracePeriod.diff(now, 'days').days + if (graceDiffDays > MAX_PERIOD_GRACE_DAYS) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'grace', + new Error(`Grace period must not exceed 365 days. Current value: ${this.heartbeat.grace} ${this.heartbeat.graceUnit} (${graceDiffDays.toFixed(2)} days)`), + )) + } + + // Validate grace is not negative + if (addedGracePeriod.diff(now, 'seconds').seconds < 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'grace', + new Error(`Grace period must be 0 or greater. Current value: ${this.heartbeat.grace} ${this.heartbeat.graceUnit}`), + )) + } + } + } + synthesize (): any | null { return { ...super.synthesize(), diff --git a/packages/cli/src/constructs/private-location.ts b/packages/cli/src/constructs/private-location.ts index 4bbecd731..f0a431394 100644 --- a/packages/cli/src/constructs/private-location.ts +++ b/packages/cli/src/constructs/private-location.ts @@ -2,7 +2,6 @@ import { Construct } from './construct' import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' import { Diagnostics } from './diagnostics' import { Session } from './project' -import { ValidationError } from './validator-error' export type PrivateLocationIcon = 'alert' | 'arrow-down' | 'arrow-left' | 'arrow-right' | 'arrow-small-down' | 'arrow-small-left' | 'arrow-small-right' | 'arrow-small-up' | 'arrow-up' | 'beaker' | 'bell' | 'bold' diff --git a/packages/cli/src/constructs/slack-alert-channel.ts b/packages/cli/src/constructs/slack-alert-channel.ts index 52e5f1cbe..88f2da0cc 100644 --- a/packages/cli/src/constructs/slack-alert-channel.ts +++ b/packages/cli/src/constructs/slack-alert-channel.ts @@ -1,5 +1,7 @@ import { AlertChannel, AlertChannelProps } from './alert-channel' import { Session } from './project' +import { Diagnostics } from './diagnostics' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' export interface SlackAlertChannelProps extends AlertChannelProps { url: URL|string @@ -35,6 +37,41 @@ export class SlackAlertChannel extends AlertChannel { return `SlackAlertChannel:${this.logicalId}` } + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + // Validate Slack webhook URL + if (this.url) { + const urlString = this.url instanceof URL ? this.url.toString() : this.url + try { + const url = new URL(urlString) + // Validate it's a Slack webhook URL + if (!url.hostname.includes('slack.com')) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'url', + new Error(`URL must be a valid Slack webhook URL. Current value: "${urlString}"`), + )) + } + } catch { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'url', + new Error(`Invalid URL format: "${urlString}". Must be a valid URL.`), + )) + } + } + + // Validate Slack channel format (optional) + if (this.channel) { + const channelRegex = /^[#@][\w-]+$/ + if (!channelRegex.test(this.channel)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'channel', + new Error(`Invalid Slack channel format: "${this.channel}". Must start with # or @ followed by alphanumeric characters, hyphens or underscores.`), + )) + } + } + } + synthesize () { return { ...super.synthesize(), diff --git a/packages/cli/src/constructs/sms-alert-channel.ts b/packages/cli/src/constructs/sms-alert-channel.ts index a71eddcb6..d68b3fb3b 100644 --- a/packages/cli/src/constructs/sms-alert-channel.ts +++ b/packages/cli/src/constructs/sms-alert-channel.ts @@ -1,5 +1,7 @@ import { AlertChannel, AlertChannelProps } from './alert-channel' import { Session } from './project' +import { Diagnostics } from './diagnostics' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' export interface SmsAlertChannelProps extends AlertChannelProps { /** @@ -41,6 +43,21 @@ export class SmsAlertChannel extends AlertChannel { return `SmsAlertChannel:${this.logicalId}` } + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + // Validate phone number format (E.164 format) + if (this.phoneNumber) { + const phoneRegex = /^\+[1-9]\d{1,14}$/ + if (!phoneRegex.test(this.phoneNumber)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'phoneNumber', + new Error(`Invalid phone number format: "${this.phoneNumber}". Must be in E.164 format (e.g., +1234567890).`), + )) + } + } + } + synthesize () { return { ...super.synthesize(), diff --git a/packages/cli/src/constructs/tcp-monitor.ts b/packages/cli/src/constructs/tcp-monitor.ts index 493b8d19e..28d4a67b8 100644 --- a/packages/cli/src/constructs/tcp-monitor.ts +++ b/packages/cli/src/constructs/tcp-monitor.ts @@ -4,6 +4,7 @@ import { Session } from './project' import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion' import { Diagnostics } from './diagnostics' import { validateResponseTimes } from './internal/common-diagnostics' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' type TcpAssertionSource = 'RESPONSE_DATA' | 'RESPONSE_TIME' @@ -158,6 +159,66 @@ export class TcpMonitor extends Monitor { async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) + // Validate request properties + if (this.request) { + // Validate port range (1-65535) + if (this.request.port !== undefined) { + if (this.request.port < 1 || this.request.port > 65535) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.port', + new Error(`Port must be between 1 and 65535. Current value: ${this.request.port}`), + )) + } + } + + // Validate IP family + const validIPFamilies = ['IPv4', 'IPv6'] + if (this.request.ipFamily && !validIPFamilies.includes(this.request.ipFamily)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.ipFamily', + new Error(`Invalid IP family "${this.request.ipFamily}". Valid values are: ${validIPFamilies.join(', ')}`), + )) + } + + // Validate hostname (should not contain scheme or port) + if (this.request.hostname) { + // Check for scheme + if (this.request.hostname.match(/^https?:\/\//i)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.hostname', + new Error(`Hostname should not include a scheme (http:// or https://). Current value: "${this.request.hostname}"`), + )) + } + + // Check for port in hostname + if (this.request.hostname.match(/:\d+$/)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.hostname', + new Error(`Hostname should not include a port number. Use the "port" property instead. Current value: "${this.request.hostname}"`), + )) + } + } + } + + // Validate response times with proper bounds + if (this.degradedResponseTime !== undefined) { + if (this.degradedResponseTime < 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'degradedResponseTime', + new Error(`The value of "degradedResponseTime" must be 0 or greater. Current value: ${this.degradedResponseTime}`), + )) + } + } + + if (this.maxResponseTime !== undefined) { + if (this.maxResponseTime < 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'maxResponseTime', + new Error(`The value of "maxResponseTime" must be 0 or greater. Current value: ${this.maxResponseTime}`), + )) + } + } + await validateResponseTimes(diagnostics, this, { degradedResponseTime: 5_000, maxResponseTime: 5_000, diff --git a/packages/cli/src/constructs/url-monitor.ts b/packages/cli/src/constructs/url-monitor.ts index 01665b059..c032027a6 100644 --- a/packages/cli/src/constructs/url-monitor.ts +++ b/packages/cli/src/constructs/url-monitor.ts @@ -3,6 +3,7 @@ import { validateResponseTimes } from './internal/common-diagnostics' import { Monitor, MonitorProps } from './monitor' import { Session } from './project' import { UrlRequest } from './url-request' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' /** * Configuration properties for UrlMonitor. @@ -139,6 +140,56 @@ export class UrlMonitor extends Monitor { async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) + // Validate request properties + if (this.request) { + // Validate URL length (max 2048 characters) + if (this.request.url && this.request.url.length > 2048) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.url', + new Error(`URL length must not exceed 2048 characters. Current length: ${this.request.url.length}`), + )) + } + + // Validate URL format (must be HTTP or HTTPS) + if (this.request.url) { + const urlRegex = /^https?:\/\//i + if (!urlRegex.test(this.request.url)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.url', + new Error(`URL must start with http:// or https://. Current value: "${this.request.url}"`), + )) + } + } + + // Validate IP family + const validIPFamilies = ['IPv4', 'IPv6'] + if (this.request.ipFamily && !validIPFamilies.includes(this.request.ipFamily)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'request.ipFamily', + new Error(`Invalid IP family "${this.request.ipFamily}". Valid values are: ${validIPFamilies.join(', ')}`), + )) + } + } + + // Validate response times with proper bounds + if (this.degradedResponseTime !== undefined) { + if (this.degradedResponseTime < 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'degradedResponseTime', + new Error(`The value of "degradedResponseTime" must be 0 or greater. Current value: ${this.degradedResponseTime}`), + )) + } + } + + if (this.maxResponseTime !== undefined) { + if (this.maxResponseTime < 0) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'maxResponseTime', + new Error(`The value of "maxResponseTime" must be 0 or greater. Current value: ${this.maxResponseTime}`), + )) + } + } + await validateResponseTimes(diagnostics, this, { degradedResponseTime: 30_000, maxResponseTime: 30_000, diff --git a/packages/cli/src/constructs/webhook-alert-channel.ts b/packages/cli/src/constructs/webhook-alert-channel.ts index 1639f5b4b..cdfa4d1a6 100644 --- a/packages/cli/src/constructs/webhook-alert-channel.ts +++ b/packages/cli/src/constructs/webhook-alert-channel.ts @@ -3,6 +3,8 @@ import { HttpHeader } from './http-header' import { HttpRequestMethod } from './http-request' import { QueryParam } from './query-param' import { Session } from './project' +import { Diagnostics } from './diagnostics' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics' export interface WebhookAlertChannelProps extends AlertChannelProps { /** @@ -82,6 +84,39 @@ export class WebhookAlertChannel extends AlertChannel { return `WebhookAlertChannel:${this.logicalId}` } + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + // Validate HTTP method + const validMethods = ['GET', 'get', 'POST', 'post', 'PUT', 'put', 'PATCH', 'patch', 'HEAD', 'head', 'DELETE', 'delete', 'OPTIONS', 'options'] + if (this.method && !validMethods.includes(this.method)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'method', + new Error(`Invalid HTTP method "${this.method}". Valid methods are: ${validMethods.join(', ')}`), + )) + } + + // Validate URL + if (this.url) { + const urlString = this.url instanceof URL ? this.url.toString() : this.url + try { + const parsedUrl = new URL(urlString) + // Only allow HTTP/HTTPS protocols for webhooks + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'url', + new Error(`Invalid URL protocol: "${parsedUrl.protocol}". Webhooks must use HTTP or HTTPS.`), + )) + } + } catch { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'url', + new Error(`Invalid URL format: "${urlString}". Must be a valid URL.`), + )) + } + } + } + synthesize () { return { ...super.synthesize(),