From 24ac88f838aaad720b3805b0716230e237913667 Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Mon, 6 Jul 2026 09:21:08 +0200 Subject: [PATCH] fix(constructs): make SSL/traceroute assertion builders emit backend-valid payloads [SIM-287] Several assertion builders synthesized payloads the backend rejects or could not express supported checks: - TracerouteAssertionBuilder.responseTime() emitted property:'' but the backend requires avg|min|max|stdDev; now defaults to 'avg' and adds avg/min/max/stdDev selectors (+ codegen preserves the property on round-trip). - SslBaselineSeverity 'warn' -> 'degrade' to match the backend enum. - Add greaterThanOrEqual and matches builders (backend needs GREATER_THAN_OR_EQUAL for KEY_SIZE_BITS/TLS_VERSION and MATCHES for CIPHER_SUITE/ISSUER_CN); wire both into codegen. - Add missing SSL sources OCSP_STAPLED, HANDSHAKE_TIME_MS, SAN_CONTAINS (+ codegen). - Add degraded<=max cross-field validation in the shared validateResponseTimes helper (accounts for the per-type default max when maxResponseTime is omitted). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NC9pESYE5wGqy5TKRzufyc --- .../__tests__/ssl-assertion-codegen.spec.ts | 55 ++++++++++++ .../constructs/__tests__/ssl-monitor.spec.ts | 87 +++++++++++++++++++ .../traceroute-assertion-codegen.spec.ts | 55 ++++++++++++ .../__tests__/traceroute-monitor.spec.ts | 53 +++++++++++ packages/cli/src/constructs/grpc-monitor.ts | 3 + .../constructs/internal/assertion-codegen.ts | 27 ++++++ .../cli/src/constructs/internal/assertion.ts | 16 ++++ .../constructs/internal/common-diagnostics.ts | 26 ++++++ .../src/constructs/ssl-assertion-codegen.ts | 6 ++ packages/cli/src/constructs/ssl-assertion.ts | 29 +++++++ packages/cli/src/constructs/ssl-monitor.ts | 22 ++--- packages/cli/src/constructs/ssl-request.ts | 4 +- .../traceroute-assertion-codegen.ts | 8 +- .../src/constructs/traceroute-assertion.ts | 51 ++++++++++- .../cli/src/constructs/traceroute-monitor.ts | 3 + 15 files changed, 425 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts diff --git a/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts b/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts new file mode 100644 index 000000000..e68335835 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' + +import { valueForSslAssertion } from '../ssl-assertion-codegen.js' +import { SslAssertion } from '../ssl-assertion.js' +import { GeneratedFile, Output } from '../../sourcegen/index.js' + +function render (assertion: SslAssertion): string { + const output = new Output() + const file = new GeneratedFile('foo.ts') + const result = valueForSslAssertion(file, assertion) + result.render(output) + return output.finalize() +} + +describe('SSL Assertion Codegen', () => { + it('generates the new SSL assertion sources', () => { + const cases: { input: SslAssertion, expected: string }[] = [ + { + input: { source: 'HANDSHAKE_TIME_MS', property: '', comparison: 'LESS_THAN', target: '500', regex: null }, + expected: 'SslAssertionBuilder.handshakeTimeMs().lessThan(500)\n', + }, + { + input: { source: 'OCSP_STAPLED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, + expected: "SslAssertionBuilder.ocspStapled().equals('true')\n", + }, + { + input: { source: 'SAN_CONTAINS', property: '', comparison: 'EQUALS', target: 'example.com', regex: null }, + expected: "SslAssertionBuilder.sanContains().equals('example.com')\n", + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) + + it('generates the new comparison operators', () => { + const cases: { input: SslAssertion, expected: string }[] = [ + { + input: { source: 'KEY_SIZE_BITS', property: '', comparison: 'GREATER_THAN_OR_EQUAL', target: '2048', regex: null }, + expected: 'SslAssertionBuilder.keySizeBits().greaterThanOrEqual(2048)\n', + }, + { + input: { source: 'TLS_VERSION', property: '', comparison: 'GREATER_THAN_OR_EQUAL', target: 'TLS1.2', regex: null }, + expected: "SslAssertionBuilder.tlsVersion().greaterThanOrEqual('TLS1.2')\n", + }, + { + input: { source: 'CIPHER_SUITE', property: '', comparison: 'MATCHES', target: '^TLS_AES_.*', regex: null }, + expected: "SslAssertionBuilder.cipherSuite().matches('^TLS_AES_.*')\n", + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) +}) diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts index d574d438d..c71c0d4f7 100644 --- a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -116,6 +116,56 @@ describe('SslMonitor', () => { expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) }) + describe('assertions', () => { + it('synthesizes the new SSL assertion sources with backend-compatible operators', () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { + hostname: 'example.com', + sslConfig: {}, + assertions: [ + SslAssertionBuilder.keySizeBits().greaterThanOrEqual(2048), + SslAssertionBuilder.tlsVersion().greaterThanOrEqual('TLS1.2'), + SslAssertionBuilder.cipherSuite().matches('^TLS_AES_.*'), + SslAssertionBuilder.issuerCn().matches('(?i)let\'s encrypt.*'), + SslAssertionBuilder.handshakeTimeMs().lessThan(500), + SslAssertionBuilder.ocspStapled().equals(true), + SslAssertionBuilder.sanContains().equals('example.com'), + ], + }, + }) + + const payload = check.synthesize() as any + expect(payload.request.assertions).toEqual([ + expect.objectContaining({ source: 'KEY_SIZE_BITS', comparison: 'GREATER_THAN_OR_EQUAL', target: '2048' }), + expect.objectContaining({ source: 'TLS_VERSION', comparison: 'GREATER_THAN_OR_EQUAL', target: 'TLS1.2' }), + expect.objectContaining({ source: 'CIPHER_SUITE', comparison: 'MATCHES', target: '^TLS_AES_.*' }), + expect.objectContaining({ source: 'ISSUER_CN', comparison: 'MATCHES' }), + expect.objectContaining({ source: 'HANDSHAKE_TIME_MS', comparison: 'LESS_THAN', target: '500' }), + expect.objectContaining({ source: 'OCSP_STAPLED', comparison: 'EQUALS', target: 'true' }), + expect.objectContaining({ source: 'SAN_CONTAINS', comparison: 'EQUALS', target: 'example.com' }), + ]) + }) + + it('accepts a "degrade" baseline severity', () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { + hostname: 'example.com', + sslConfig: { + securityBaseline: { + minTLSVersion: { value: 'TLS1.2', severity: 'degrade' }, + }, + }, + }, + }) + const payload = check.synthesize() as any + expect(payload.request.sslConfig.securityBaseline.minTLSVersion.severity).toBe('degrade') + }) + }) + describe('validation', () => { it('should error when clientCertificateMode is explicit but no certificate id is set', async () => { setupProject() @@ -176,6 +226,43 @@ describe('SslMonitor', () => { ])) }) + it('should error when degradedResponseTime exceeds the default maxResponseTime and max is omitted', async () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + // 20000 is within the 30000 degraded bound but above the 10000 default + // maxResponseTime the backend applies when max is omitted. + degradedResponseTime: 20000, + request: { + hostname: 'example.com', + sslConfig: {}, + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be less than or equal to the default "maxResponseTime" of 10000'), + }), + ])) + }) + + it('should not error when degradedResponseTime is within the default maxResponseTime and max is omitted', async () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + degradedResponseTime: 5000, + request: { + hostname: 'example.com', + sslConfig: {}, + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + it('should not error for a valid config', async () => { setupProject() const check = new SslMonitor('test-check', { name: 'Test Check', request }) diff --git a/packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts b/packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts new file mode 100644 index 000000000..04a54e5b3 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' + +import { valueForTracerouteAssertion } from '../traceroute-assertion-codegen.js' +import { TracerouteAssertion } from '../traceroute-assertion.js' +import { GeneratedFile, Output } from '../../sourcegen/index.js' + +function render (assertion: TracerouteAssertion): string { + const output = new Output() + const file = new GeneratedFile('foo.ts') + const result = valueForTracerouteAssertion(file, assertion) + result.render(output) + return output.finalize() +} + +describe('Traceroute Assertion Codegen', () => { + it('preserves the response-time property as a selector', () => { + const cases: { input: TracerouteAssertion, expected: string }[] = [ + { + input: { source: 'RESPONSE_TIME', property: 'max', comparison: 'LESS_THAN', target: '1000', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().max().lessThan(1000)\n', + }, + { + input: { source: 'RESPONSE_TIME', property: 'min', comparison: 'GREATER_THAN', target: '10', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().min().greaterThan(10)\n', + }, + { + input: { source: 'RESPONSE_TIME', property: 'stdDev', comparison: 'LESS_THAN', target: '50', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().stdDev().lessThan(50)\n', + }, + { + input: { source: 'RESPONSE_TIME', property: 'avg', comparison: 'LESS_THAN', target: '1000', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().avg().lessThan(1000)\n', + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) + + it('emits a bare responseTime() when no property is set', () => { + const input: TracerouteAssertion = + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null } + expect(render(input)).toEqual('TracerouteAssertionBuilder.responseTime().lessThan(1000)\n') + }) + + it('does not emit a property selector for HOP_COUNT / PACKET_LOSS', () => { + const hop: TracerouteAssertion = + { source: 'HOP_COUNT', property: '', comparison: 'LESS_THAN', target: '20', regex: null } + expect(render(hop)).toEqual('TracerouteAssertionBuilder.hopCount().lessThan(20)\n') + + const loss: TracerouteAssertion = + { source: 'PACKET_LOSS', property: '', comparison: 'LESS_THAN', target: '10', regex: null } + expect(render(loss)).toEqual('TracerouteAssertionBuilder.packetLoss().lessThan(10)\n') + }) +}) diff --git a/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts b/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts index d7ad93c2e..6d14be806 100644 --- a/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts @@ -84,7 +84,60 @@ describe('TracerouteMonitor', () => { expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) }) + describe('responseTime() assertions', () => { + it('should default the property to "avg" so responseTime().lessThan() is usable', () => { + const assertion = TracerouteAssertionBuilder.responseTime().lessThan(1000) + expect(assertion).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'avg', + comparison: 'LESS_THAN', + target: '1000', + }) + }) + + it('should support selecting a specific response-time property', () => { + expect(TracerouteAssertionBuilder.responseTime().max().lessThan(2000)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'max', + comparison: 'LESS_THAN', + target: '2000', + }) + expect(TracerouteAssertionBuilder.responseTime().min().greaterThan(1)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'min', + comparison: 'GREATER_THAN', + }) + expect(TracerouteAssertionBuilder.responseTime().stdDev().lessThan(50)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'stdDev', + }) + expect(TracerouteAssertionBuilder.responseTime().avg().equals(100)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'avg', + comparison: 'EQUALS', + }) + }) + }) + describe('validation', () => { + it('should error when degradedResponseTime exceeds maxResponseTime', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 20000, + maxResponseTime: 10000, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be less than or equal to "maxResponseTime"'), + }), + ])) + }) + it('should error if maxResponseTime is above 30000', async () => { setupProject() const check = new TracerouteMonitor('test-check', { diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts index e8be032e7..881a93097 100644 --- a/packages/cli/src/constructs/grpc-monitor.ts +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -78,6 +78,9 @@ export class GrpcMonitor extends Monitor { await validateResponseTimes(diagnostics, this, { degradedResponseTime: 30_000, maxResponseTime: 30_000, + // Backend default applied when maxResponseTime is omitted (see + // grpcResponseTimeLimitFields in response-time-limit-schema.ts). + defaultMaxResponseTime: 20_000, }) } diff --git a/packages/cli/src/constructs/internal/assertion-codegen.ts b/packages/cli/src/constructs/internal/assertion-codegen.ts index cfd152ccf..df4626a57 100644 --- a/packages/cli/src/constructs/internal/assertion-codegen.ts +++ b/packages/cli/src/constructs/internal/assertion-codegen.ts @@ -3,6 +3,10 @@ import { Assertion } from './assertion.js' export interface ValueForNumericAssertionOptions { hasProperty?: boolean + // When set, emit the assertion property as a chained selector method call + // (e.g. `responseTime().max()`) instead of a method argument, but only when + // the property is one of the listed selector names. + propertySelectors?: readonly string[] } export function valueForNumericAssertion ( @@ -19,6 +23,11 @@ export function valueForNumericAssertion ( builder.string(assertion.property) } }) + const propertySelectors = options?.propertySelectors + if (propertySelectors !== undefined && propertySelectors.includes(assertion.property)) { + builder.member(ident(assertion.property)) + builder.call(() => {}) + } switch (assertion.comparison) { case 'EQUALS': builder.member(ident('equals')) @@ -44,6 +53,12 @@ export function valueForNumericAssertion ( builder.number(parseInt(assertion.target, 10)) }) break + case 'GREATER_THAN_OR_EQUAL': + builder.member(ident('greaterThanOrEqual')) + builder.call(builder => { + builder.number(parseInt(assertion.target, 10)) + }) + break default: throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`) } @@ -135,6 +150,18 @@ export function valueForGeneralAssertion ( builder.string(assertion.target) }) break + case 'GREATER_THAN_OR_EQUAL': + builder.member(ident('greaterThanOrEqual')) + builder.call(builder => { + builder.string(assertion.target) + }) + break + case 'MATCHES': + builder.member(ident('matches')) + builder.call(builder => { + builder.string(assertion.target) + }) + break case 'CONTAINS': builder.member(ident('contains')) builder.call(builder => { diff --git a/packages/cli/src/constructs/internal/assertion.ts b/packages/cli/src/constructs/internal/assertion.ts index 26d1c8a30..6c12d60d9 100644 --- a/packages/cli/src/constructs/internal/assertion.ts +++ b/packages/cli/src/constructs/internal/assertion.ts @@ -8,9 +8,11 @@ type Comparison = | 'IS_EMPTY' | 'NOT_EMPTY' | 'GREATER_THAN' + | 'GREATER_THAN_OR_EQUAL' | 'LESS_THAN' | 'CONTAINS' | 'NOT_CONTAINS' + | 'MATCHES' | 'IS_NULL' | 'NOT_NULL' @@ -47,6 +49,10 @@ export class NumericAssertionBuilder { + return this._toAssertion('GREATER_THAN_OR_EQUAL', target) + } + /** @private */ private _toAssertion (comparison: Comparison, target: number): Assertion { return { @@ -110,6 +116,10 @@ export class GeneralAssertionBuilder { return this._toAssertion('GREATER_THAN', target) } + greaterThanOrEqual (target: string | number | boolean): Assertion { + return this._toAssertion('GREATER_THAN_OR_EQUAL', target) + } + contains (target: string): Assertion { return this._toAssertion('CONTAINS', target) } @@ -118,6 +128,12 @@ export class GeneralAssertionBuilder { return this._toAssertion('NOT_CONTAINS', target) } + matches (regex: string): Assertion { + // MATCHES carries the regular expression in the `target` field (the runner + // compiles `target` as the pattern), mirroring EQUALS/CONTAINS. + return this._toAssertion('MATCHES', regex) + } + isNull () { return this._toAssertion('IS_NULL') } diff --git a/packages/cli/src/constructs/internal/common-diagnostics.ts b/packages/cli/src/constructs/internal/common-diagnostics.ts index b2f9bc99f..52a5fcdf5 100644 --- a/packages/cli/src/constructs/internal/common-diagnostics.ts +++ b/packages/cli/src/constructs/internal/common-diagnostics.ts @@ -129,6 +129,10 @@ type ResponseTimeProps = { type ResponseTimeLimits = { degradedResponseTime: number maxResponseTime: number + // The per-type default that the backend applies to `maxResponseTime` when the + // caller omits it. Used so the degraded <= max cross-check still runs (against + // the effective max) when only `degradedResponseTime` is set explicitly. + defaultMaxResponseTime?: number } // eslint-disable-next-line require-await @@ -166,4 +170,26 @@ export async function validateResponseTimes ( )) } } + + // When `maxResponseTime` is omitted the backend applies a per-type default, so + // compare `degradedResponseTime` against that effective max. This catches the + // case where an explicit degraded value exceeds the default max (which would + // otherwise pass CLI validation but produce a broken check server-side). + const effectiveMaxResponseTime = props.maxResponseTime ?? limits.defaultMaxResponseTime + if ( + props.degradedResponseTime !== undefined + && effectiveMaxResponseTime !== undefined + && props.degradedResponseTime > effectiveMaxResponseTime + ) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'degradedResponseTime', + new Error( + props.maxResponseTime !== undefined + ? `The value of "degradedResponseTime" must be less than or equal to "maxResponseTime".` + : `The value of "degradedResponseTime" must be less than or equal to the default ` + + `"maxResponseTime" of ${effectiveMaxResponseTime}. Set an explicit "maxResponseTime" ` + + `if you need a higher limit.`, + ), + )) + } } diff --git a/packages/cli/src/constructs/ssl-assertion-codegen.ts b/packages/cli/src/constructs/ssl-assertion-codegen.ts index bce14c81c..b391883e0 100644 --- a/packages/cli/src/constructs/ssl-assertion-codegen.ts +++ b/packages/cli/src/constructs/ssl-assertion-codegen.ts @@ -30,6 +30,12 @@ export function valueForSslAssertion (genfile: GeneratedFile, assertion: SslAsse return valueForGeneralAssertion('SslAssertionBuilder', 'issuerFingerprintSha256', assertion, generalNoArgs) case 'SIGNATURE_ALGORITHM': return valueForGeneralAssertion('SslAssertionBuilder', 'signatureAlgorithm', assertion, generalNoArgs) + case 'OCSP_STAPLED': + return valueForGeneralAssertion('SslAssertionBuilder', 'ocspStapled', assertion, generalNoArgs) + case 'HANDSHAKE_TIME_MS': + return valueForNumericAssertion('SslAssertionBuilder', 'handshakeTimeMs', assertion) + case 'SAN_CONTAINS': + return valueForGeneralAssertion('SslAssertionBuilder', 'sanContains', assertion, generalNoArgs) default: throw new Error(`Unsupported SSL assertion source ${assertion.source}`) } diff --git a/packages/cli/src/constructs/ssl-assertion.ts b/packages/cli/src/constructs/ssl-assertion.ts index d38ea5704..a64e3b309 100644 --- a/packages/cli/src/constructs/ssl-assertion.ts +++ b/packages/cli/src/constructs/ssl-assertion.ts @@ -12,6 +12,9 @@ type SslAssertionSource = | 'ISSUER_FINGERPRINT_SHA256' | 'KEY_SIZE_BITS' | 'SIGNATURE_ALGORITHM' + | 'OCSP_STAPLED' + | 'HANDSHAKE_TIME_MS' + | 'SAN_CONTAINS' export type SslAssertion = CoreAssertion @@ -122,4 +125,30 @@ export class SslAssertionBuilder { static signatureAlgorithm () { return new GeneralAssertionBuilder('SIGNATURE_ALGORITHM') } + + /** + * Creates an assertion builder for whether a stapled OCSP response was + * provided during the handshake. + * @returns A general assertion builder for the OCSP-stapled status. + */ + static ocspStapled () { + return new GeneralAssertionBuilder('OCSP_STAPLED') + } + + /** + * Creates an assertion builder for the TLS handshake time in milliseconds. + * @returns A numeric assertion builder for the handshake time. + */ + static handshakeTimeMs () { + return new NumericAssertionBuilder('HANDSHAKE_TIME_MS') + } + + /** + * Creates an assertion builder for the certificate subject alternative names + * (SANs). + * @returns A general assertion builder for the SAN entries. + */ + static sanContains () { + return new GeneralAssertionBuilder('SAN_CONTAINS') + } } diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index 38b4c6321..85505da60 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -2,7 +2,8 @@ import { Monitor, MonitorProps } from './monitor.js' import { Session } from './session.js' import { Diagnostics } from './diagnostics.js' import { SslRequest } from './ssl-request.js' -import { InvalidPropertyValueDiagnostic, RequiredPropertyDiagnostic } from './construct-diagnostics.js' +import { validateResponseTimes } from './internal/common-diagnostics.js' +import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' export interface SslMonitorProps extends MonitorProps { /** @@ -78,18 +79,13 @@ export class SslMonitor extends Monitor { )) } - if ( - this.degradedResponseTime !== undefined - && this.maxResponseTime !== undefined - && this.degradedResponseTime > this.maxResponseTime - ) { - diagnostics.add(new InvalidPropertyValueDiagnostic( - 'degradedResponseTime', - new Error( - `The value of "degradedResponseTime" must be less than or equal to "maxResponseTime".`, - ), - )) - } + await validateResponseTimes(diagnostics, this, { + degradedResponseTime: 30_000, + maxResponseTime: 30_000, + // Backend default applied when maxResponseTime is omitted (see + // sslMonitorResponseTimeLimitFields in response-time-limit-schema.ts). + defaultMaxResponseTime: 10_000, + }) } synthesize () { diff --git a/packages/cli/src/constructs/ssl-request.ts b/packages/cli/src/constructs/ssl-request.ts index e9ca3791f..0a1c0aea1 100644 --- a/packages/cli/src/constructs/ssl-request.ts +++ b/packages/cli/src/constructs/ssl-request.ts @@ -5,10 +5,10 @@ import { IPFamily } from './ip.js' * The severity applied to an SSL security-baseline rule. * * - `fail` marks the monitor as failing when the rule is violated. - * - `warn` marks the monitor as degraded when the rule is violated. + * - `degrade` marks the monitor as degraded when the rule is violated. * - `ignore` disables the rule. */ -export type SslBaselineSeverity = 'fail' | 'warn' | 'ignore' +export type SslBaselineSeverity = 'fail' | 'degrade' | 'ignore' /** * A baseline rule whose value is a TLS version string (e.g. `TLS1.2`/`TLS1.3`). diff --git a/packages/cli/src/constructs/traceroute-assertion-codegen.ts b/packages/cli/src/constructs/traceroute-assertion-codegen.ts index 76f217ff0..cd17cf356 100644 --- a/packages/cli/src/constructs/traceroute-assertion-codegen.ts +++ b/packages/cli/src/constructs/traceroute-assertion-codegen.ts @@ -7,7 +7,13 @@ export function valueForTracerouteAssertion (genfile: GeneratedFile, assertion: switch (assertion.source) { case 'RESPONSE_TIME': - return valueForNumericAssertion('TracerouteAssertionBuilder', 'responseTime', assertion) + // The forward API selects the response-time property via a chained method + // (`responseTime().max().lessThan(...)`), so emit the property as a + // selector instead of dropping it. An empty/unknown property round-trips + // to a bare `responseTime()` (which defaults to `avg`). + return valueForNumericAssertion('TracerouteAssertionBuilder', 'responseTime', assertion, { + propertySelectors: ['avg', 'min', 'max', 'stdDev'], + }) case 'HOP_COUNT': return valueForNumericAssertion('TracerouteAssertionBuilder', 'hopCount', assertion) case 'PACKET_LOSS': diff --git a/packages/cli/src/constructs/traceroute-assertion.ts b/packages/cli/src/constructs/traceroute-assertion.ts index c625559db..e1d107a42 100644 --- a/packages/cli/src/constructs/traceroute-assertion.ts +++ b/packages/cli/src/constructs/traceroute-assertion.ts @@ -7,15 +7,56 @@ type TracerouteAssertionSource = export type TracerouteAssertion = CoreAssertion +/** + * The statistical property of the traceroute response time to assert against. + * The backend requires one of these for `RESPONSE_TIME` assertions. + */ +export type TracerouteResponseTimeProperty = 'avg' | 'min' | 'max' | 'stdDev' + +/** + * A numeric assertion builder for traceroute response time. It defaults to the + * `avg` property so `responseTime().lessThan(1000)` works out of the box, and + * exposes `avg()`/`min()`/`max()`/`stdDev()` to select a specific property. + */ +class TracerouteResponseTimeAssertionBuilder + extends NumericAssertionBuilder { + constructor () { + super('RESPONSE_TIME', 'avg') + } + + /** Asserts against the average response time. */ + avg () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'avg') + } + + /** Asserts against the minimum response time. */ + min () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'min') + } + + /** Asserts against the maximum response time. */ + max () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'max') + } + + /** Asserts against the standard deviation of the response time. */ + stdDev () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'stdDev') + } +} + /** * Builder class for creating traceroute monitor assertions. * Provides methods to create assertions for traceroute probe responses. * * @example * ```typescript - * // Response time assertions + * // Response time assertions (defaults to the average) * TracerouteAssertionBuilder.responseTime().lessThan(1000) * + * // Response time assertions against a specific property + * TracerouteAssertionBuilder.responseTime().max().lessThan(2000) + * * // Hop count assertions * TracerouteAssertionBuilder.hopCount().lessThan(20) * @@ -25,11 +66,13 @@ export type TracerouteAssertion = CoreAssertion */ export class TracerouteAssertionBuilder { /** - * Creates an assertion builder for traceroute response time. - * @returns A numeric assertion builder for response time in milliseconds. + * Creates an assertion builder for traceroute response time in milliseconds. + * Defaults to the `avg` property; use `avg()`/`min()`/`max()`/`stdDev()` to + * select a specific property. + * @returns A numeric assertion builder for response time. */ static responseTime () { - return new NumericAssertionBuilder('RESPONSE_TIME') + return new TracerouteResponseTimeAssertionBuilder() } /** diff --git a/packages/cli/src/constructs/traceroute-monitor.ts b/packages/cli/src/constructs/traceroute-monitor.ts index a38345cbc..c39e3c9f4 100644 --- a/packages/cli/src/constructs/traceroute-monitor.ts +++ b/packages/cli/src/constructs/traceroute-monitor.ts @@ -78,6 +78,9 @@ export class TracerouteMonitor extends Monitor { await validateResponseTimes(diagnostics, this, { degradedResponseTime: 30_000, maxResponseTime: 30_000, + // Backend default applied when maxResponseTime is omitted (see + // tracerouteResponseTimeLimitFields in response-time-limit-schema.ts). + defaultMaxResponseTime: 20_000, }) }