diff --git a/packages/cli/src/commands/authCommand.ts b/packages/cli/src/commands/authCommand.ts index 6e58e1fc..d93a0aca 100644 --- a/packages/cli/src/commands/authCommand.ts +++ b/packages/cli/src/commands/authCommand.ts @@ -2,6 +2,7 @@ import prompts from 'prompts' import { BaseCommand } from './baseCommand.js' import * as api from '../rest/api.js' import { Account } from '../rest/accounts.js' +import { Session } from '../constructs/session.js' import { detectCliMode } from '../helpers/cli-mode.js' import type { CommandPreview } from '../helpers/command-preview.js' import { formatPreviewForAgent, formatPreviewForTerminal } from '../helpers/command-preview.js' @@ -22,6 +23,9 @@ export abstract class AuthCommand extends BaseCommand { protected async init (): Promise { await super.init() this.#account = await api.validateAuthentication() + // Constructs validate against account-specific limits and have no access to + // the command instance. + Session.accountFeatures = this.#account?.features ?? [] } protected async confirmOrAbort ( diff --git a/packages/cli/src/constructs/__tests__/account-features.spec.ts b/packages/cli/src/constructs/__tests__/account-features.spec.ts new file mode 100644 index 00000000..5aa6375f --- /dev/null +++ b/packages/cli/src/constructs/__tests__/account-features.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, afterEach } from 'vitest' + +import { ApiCheck, TcpMonitor, TcpRequest, UrlMonitor } from '../index.js' +import { Diagnostics } from '../diagnostics.js' +import { Project } from '../project.js' +import { Session } from '../session.js' + +const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS' + +const tcpRequest: TcpRequest = { + hostname: 'acme.com', + port: 443, +} + +const newProject = () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) +} + +const validate = async (construct: { validate: (diagnostics: Diagnostics) => Promise }) => { + const diagnostics = new Diagnostics() + await construct.validate(diagnostics) + return diagnostics +} + +describe('account-aware response time limits', () => { + afterEach(() => { + Session.accountFeatures = [] + }) + + it('rejects an extended response time without the feature', async () => { + newProject() + Session.accountFeatures = [] + + const diagnostics = await validate(new TcpMonitor('tcp-standard', { + name: 'Test Check', + request: tcpRequest, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be 5000 or lower'), + }), + ])) + }) + + it('accepts an extended response time with the feature', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new TcpMonitor('tcp-extended', { + name: 'Test Check', + request: tcpRequest, + degradedResponseTime: 40_000, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('defers values above 45s to the API with the feature', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new TcpMonitor('tcp-over-extended', { + name: 'Test Check', + request: tcpRequest, + maxResponseTime: 300_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('keeps the degraded <= max cross-check with the feature', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new TcpMonitor('tcp-degraded-above-max', { + name: 'Test Check', + request: tcpRequest, + degradedResponseTime: 46_000, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(true) + expect(diagnostics.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be less than or equal to "maxResponseTime"'), + }), + ])) + }) + + it('raises the API check limit above its 30s standard', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new ApiCheck('api-extended', { + name: 'Test Check', + request: { url: 'https://acme.com', method: 'GET' }, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) + + it('raises the URL monitor limit above its 30s standard', async () => { + newProject() + Session.accountFeatures = [EXTENDED_RESPONSE_TIME_LIMITS] + + const diagnostics = await validate(new UrlMonitor('url-extended', { + name: 'Test Check', + request: { url: 'https://acme.com' }, + maxResponseTime: 45_000, + })) + + expect(diagnostics.isFatal()).toBe(false) + }) +}) diff --git a/packages/cli/src/constructs/api-check.ts b/packages/cli/src/constructs/api-check.ts index 06fd0ea4..790d2eee 100644 --- a/packages/cli/src/constructs/api-check.ts +++ b/packages/cli/src/constructs/api-check.ts @@ -10,6 +10,7 @@ import { Diagnostics } from './diagnostics.js' import { DeprecatedPropertyDiagnostic, InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' import { ApiCheckBundle, ApiCheckBundleProps } from './api-check-bundle.js' import { Assertion } from './api-assertion.js' +import { responseTimeLimits } from './internal/account-features.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { Bundler } from '../services/check-parser/bundler.js' @@ -257,10 +258,7 @@ export class ApiCheck extends RuntimeCheck { } } - await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, - }) + await validateResponseTimes(diagnostics, this, responseTimeLimits(30_000)) } async bundle (bundler: Bundler): Promise { diff --git a/packages/cli/src/constructs/dns-monitor.ts b/packages/cli/src/constructs/dns-monitor.ts index af95d56b..2fcd43e0 100644 --- a/packages/cli/src/constructs/dns-monitor.ts +++ b/packages/cli/src/constructs/dns-monitor.ts @@ -4,6 +4,7 @@ import { Diagnostics } from './diagnostics.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { DnsRequest } from './dns-request.js' import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' export interface DnsMonitorProps extends MonitorProps { /** @@ -99,8 +100,7 @@ export class DnsMonitor extends Monitor { } await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 5_000, - maxResponseTime: 5_000, + ...responseTimeLimits(5_000), }) } diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts index 0cb90931..a197fb7d 100644 --- a/packages/cli/src/constructs/grpc-monitor.ts +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -4,6 +4,7 @@ import { Diagnostics } from './diagnostics.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { validateGrpcAssertion } from './grpc-assertion-validation.js' import { GrpcRequest } from './grpc-request.js' +import { responseTimeLimits } from './internal/account-features.js' export interface GrpcMonitorProps extends MonitorProps { /** @@ -79,8 +80,7 @@ export class GrpcMonitor extends Monitor { await validateResponseTimes(diagnostics, this, { // gRPC allows thresholds up to 180s (calls can run to the 180s timeout), // matching the backend's gRPC response-time limits. - degradedResponseTime: 180_000, - maxResponseTime: 180_000, + ...responseTimeLimits(180_000), // Backend default applied when maxResponseTime is omitted. defaultMaxResponseTime: 20_000, }) diff --git a/packages/cli/src/constructs/internal/account-features.ts b/packages/cli/src/constructs/internal/account-features.ts new file mode 100644 index 00000000..03961b67 --- /dev/null +++ b/packages/cli/src/constructs/internal/account-features.ts @@ -0,0 +1,27 @@ +import { Session } from '../session.js' +import type { ResponseTimeLimits } from './common-diagnostics.js' + +// Raises the response time limits the API accepts for TCP, API and URL checks. +const EXTENDED_RESPONSE_TIME_LIMITS = 'EXTENDED_RESPONSE_TIME_LIMITS' + +/** + * Response time limits for the authenticated account. + * + * Accounts with extended limits skip the client-side cap entirely: the exact + * limit lives server-side and can change without a CLI release, so the CLI + * defers to the API instead of hardcoding a ceiling. The degraded <= max + * cross-check still applies. + * + * Falls back to the standard limit when the account is not entitled, and when + * 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) + ? Number.MAX_SAFE_INTEGER + : standard + + return { + degradedResponseTime: limit, + maxResponseTime: limit, + } +} diff --git a/packages/cli/src/constructs/internal/common-diagnostics.ts b/packages/cli/src/constructs/internal/common-diagnostics.ts index 52a5fcdf..e97c45ac 100644 --- a/packages/cli/src/constructs/internal/common-diagnostics.ts +++ b/packages/cli/src/constructs/internal/common-diagnostics.ts @@ -126,7 +126,7 @@ type ResponseTimeProps = { maxResponseTime?: number } -type ResponseTimeLimits = { +export type ResponseTimeLimits = { degradedResponseTime: number maxResponseTime: number // The per-type default that the backend applies to `maxResponseTime` when the diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index 94bc6a39..1af9d900 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -66,6 +66,7 @@ export class Session { static verifyRuntimeDependencies = true static loadingChecklyConfigFile: boolean static checklyConfigFileConstructs?: Construct[] + static accountFeatures: string[] = [] static privateLocations: PrivateLocationApi[] static parsers = new Map() static playwrightProjectBundler?: PlaywrightProjectBundler @@ -91,6 +92,7 @@ export class Session { this.verifyRuntimeDependencies = true this.loadingChecklyConfigFile = false this.checklyConfigFileConstructs = undefined + this.accountFeatures = [] this.privateLocations = [] this.parsers = new Map() this.playwrightProjectBundler = undefined diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index 438755b7..ef34379a 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -5,6 +5,7 @@ import { SslRequest } from './ssl-request.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { validateSslAssertion } from './ssl-assertion-validation.js' import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' export interface SslMonitorProps extends MonitorProps { /** @@ -81,8 +82,7 @@ export class SslMonitor extends Monitor { } await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, + ...responseTimeLimits(30_000), // Backend default applied when maxResponseTime is omitted. defaultMaxResponseTime: 10_000, }) diff --git a/packages/cli/src/constructs/tcp-monitor.ts b/packages/cli/src/constructs/tcp-monitor.ts index 599b63f2..c821eed0 100644 --- a/packages/cli/src/constructs/tcp-monitor.ts +++ b/packages/cli/src/constructs/tcp-monitor.ts @@ -3,6 +3,7 @@ import { IPFamily } from './ip.js' import { Session } from './session.js' import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion.js' import { Diagnostics } from './diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' import { validateResponseTimes } from './internal/common-diagnostics.js' type TcpAssertionSource = 'RESPONSE_DATA' | 'RESPONSE_TIME' @@ -158,10 +159,7 @@ export class TcpMonitor extends Monitor { async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) - await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 5_000, - maxResponseTime: 5_000, - }) + await validateResponseTimes(diagnostics, this, responseTimeLimits(5_000)) } synthesize () { diff --git a/packages/cli/src/constructs/traceroute-monitor.ts b/packages/cli/src/constructs/traceroute-monitor.ts index 8c8fda57..0884bbd0 100644 --- a/packages/cli/src/constructs/traceroute-monitor.ts +++ b/packages/cli/src/constructs/traceroute-monitor.ts @@ -4,6 +4,7 @@ import { Diagnostics } from './diagnostics.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { validateTracerouteAssertion } from './traceroute-assertion-validation.js' import { TracerouteRequest } from './traceroute-request.js' +import { responseTimeLimits } from './internal/account-features.js' export interface TracerouteMonitorProps extends MonitorProps { /** @@ -77,8 +78,7 @@ export class TracerouteMonitor extends Monitor { await super.validate(diagnostics) await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, + ...responseTimeLimits(30_000), // Backend default applied when maxResponseTime is omitted. defaultMaxResponseTime: 20_000, }) diff --git a/packages/cli/src/constructs/url-monitor.ts b/packages/cli/src/constructs/url-monitor.ts index 271a9918..6e64d362 100644 --- a/packages/cli/src/constructs/url-monitor.ts +++ b/packages/cli/src/constructs/url-monitor.ts @@ -1,4 +1,5 @@ import { Diagnostics } from './diagnostics.js' +import { responseTimeLimits } from './internal/account-features.js' import { validateResponseTimes } from './internal/common-diagnostics.js' import { Monitor, MonitorProps } from './monitor.js' import { Session } from './session.js' @@ -139,10 +140,7 @@ export class UrlMonitor extends Monitor { async validate (diagnostics: Diagnostics): Promise { await super.validate(diagnostics) - await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, - }) + await validateResponseTimes(diagnostics, this, responseTimeLimits(30_000)) } synthesize () { diff --git a/packages/cli/src/rest/accounts.ts b/packages/cli/src/rest/accounts.ts index eff33dc8..13326d1e 100644 --- a/packages/cli/src/rest/accounts.ts +++ b/packages/cli/src/rest/accounts.ts @@ -12,6 +12,11 @@ export interface Account { plan?: string planDisplayName?: string addons?: Record + /** + * Client-visible account feature flags. Absent when the API predates them, + * in which case standard limits apply. + */ + features?: string[] } class Accounts {