diff --git a/packages/cli/src/ai-context/references/configure-traceroute-monitors.md b/packages/cli/src/ai-context/references/configure-traceroute-monitors.md index 993d78106..163584753 100644 --- a/packages/cli/src/ai-context/references/configure-traceroute-monitors.md +++ b/packages/cli/src/ai-context/references/configure-traceroute-monitors.md @@ -8,6 +8,7 @@ - Use `request.maxHops` (default: 30) and `request.maxUnknownHops` (default: 15) to control trace depth. - Use `degradedResponseTime` and `maxResponseTime` (milliseconds) to configure response time thresholds. - Traceroute assertions support `RESPONSE_TIME`, `HOP_COUNT`, and `PACKET_LOSS` sources. +- `TracerouteAssertionBuilder.responseTime()` takes an optional property — `'avg'` (default), `'min'`, `'max'`, or `'stdDev'`. `hopCount()` and `packetLoss()` take no property. - **Plan-gated properties:** `retryStrategy`, `runParallel`, and higher frequencies are not available on all plans. Check entitlements matching `UPTIME_CHECKS_*` before using these. Omit any property whose entitlement is disabled. See `npx checkly skills manage` for details. diff --git a/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts b/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts index 906e580d7..782d5484a 100644 --- a/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts @@ -109,19 +109,49 @@ describe('GrpcMonitor', () => { }) describe('validation', () => { - it('should error if degradedResponseTime is above 30000', async () => { + it('should error if degradedResponseTime is above 180000', async () => { setupProject() const check = new GrpcMonitor('test-check', { name: 'Test Check', request, - degradedResponseTime: 30001, + degradedResponseTime: 180001, }) const diags = new Diagnostics() await check.validate(diags) expect(diags.isFatal()).toEqual(true) expect(diags.observations).toEqual(expect.arrayContaining([ expect.objectContaining({ - message: expect.stringContaining('The value of "degradedResponseTime" must be 30000 or lower.'), + message: expect.stringContaining('The value of "degradedResponseTime" must be 180000 or lower.'), + }), + ])) + }) + + it('should not error for a response time above 30000 (gRPC allows up to 180000)', async () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 90000, + maxResponseTime: 120000, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + + it('should error when degradedResponseTime exceeds the default maxResponseTime', async () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 30000, + }) + 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 20000'), }), ])) }) @@ -138,5 +168,91 @@ describe('GrpcMonitor', () => { await check.validate(diags) expect(diags.isFatal()).toEqual(false) }) + + it('should error on an assertion with an unknown source', async () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + // HOP_COUNT belongs to traceroute, not gRPC. + assertions: [{ source: 'HOP_COUNT', property: '', comparison: 'LESS_THAN', target: '5', regex: null }], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The assertion at "request.assertions[0]" has an unknown source "HOP_COUNT".', + ), + }), + ])) + }) + + it('should error on an assertion with an unsupported comparison', async () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + // GREATER_THAN_OR_EQUAL is an SSL-only operator, not in the gRPC set. + assertions: [ + { source: 'GRPC_STATUS_CODE', property: '', comparison: 'GREATER_THAN_OR_EQUAL', target: '0', regex: null }, + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('has an unsupported comparison "GREATER_THAN_OR_EQUAL"'), + }), + ])) + }) + + it('should accept every comparison in the backend gRPC set', async () => { + setupProject() + // Pins the full accepted set so silently dropping one from the validator's list + // is caught, since it is not compile-checked against a union. + const comparisons = [ + 'EQUALS', 'NOT_EQUALS', 'HAS_KEY', 'NOT_HAS_KEY', 'HAS_VALUE', 'NOT_HAS_VALUE', + 'IS_EMPTY', 'NOT_EMPTY', 'GREATER_THAN', 'LESS_THAN', 'CONTAINS', 'NOT_CONTAINS', + 'IS_NULL', 'NOT_NULL', + ] + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: comparisons.map(comparison => ( + { source: 'GRPC_RESPONSE', property: '', comparison, target: 'x', regex: null } + )), + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + + it('should not error on a property carried by any gRPC source', async () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + // gRPC places no source/property constraint, so a property on RESPONSE_TIME + // (which the traceroute validator would reject) is accepted here. + assertions: [ + { source: 'RESPONSE_TIME', property: 'avg', comparison: 'LESS_THAN', target: '5000', regex: null }, + GrpcAssertionBuilder.responseMetadata('content-type').contains('grpc'), + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) }) }) 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..338ab3ef3 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts @@ -0,0 +1,46 @@ +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 }[] = [ + // Boolean sources emit a boolean literal, not a quoted string. + { + input: { source: 'OCSP_STAPLED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, + expected: 'SslAssertionBuilder.ocspStapled().equals(true)\n', + }, + { + input: { source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, + expected: 'SslAssertionBuilder.certNotExpired().equals(true)\n', + }, + { + input: { source: 'CHAIN_TRUSTED', property: '', comparison: 'EQUALS', target: 'false', regex: null }, + expected: 'SslAssertionBuilder.chainTrusted().equals(false)\n', + }, + // Numeric key size emits an unquoted number. + { + input: { source: 'KEY_SIZE_BITS', property: '', comparison: 'EQUALS', target: '2048', regex: null }, + expected: 'SslAssertionBuilder.keySizeBits().equals(2048)\n', + }, + // The string sources support the MATCHES (regex) operator. + { + input: { source: 'CIPHER_SUITE', property: '', comparison: 'MATCHES', target: 'TLS_(AES|CHACHA)', regex: null }, + expected: 'SslAssertionBuilder.cipherSuite().matches(\'TLS_(AES|CHACHA)\')\n', + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) +}) diff --git a/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts b/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts index 4b9757f36..e64de275e 100644 --- a/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-assertion.spec.ts @@ -98,4 +98,46 @@ describe('SslAssertionBuilder — typed target values', () => { expect(SslAssertionBuilder.cipherSuite().equals('TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256')).toBeTruthy() }) }) + + describe('matches() — regex operator on the string sources', () => { + it('produces a MATCHES assertion for cipherSuite / issuerCn / signatureAlgorithm', () => { + expect(SslAssertionBuilder.cipherSuite().matches('TLS_(AES|CHACHA)')).toMatchObject({ + source: 'CIPHER_SUITE', comparison: 'MATCHES', target: 'TLS_(AES|CHACHA)', + }) + expect(SslAssertionBuilder.issuerCn().matches('^Let\'s Encrypt')).toMatchObject({ + source: 'ISSUER_CN', comparison: 'MATCHES', target: '^Let\'s Encrypt', + }) + expect(SslAssertionBuilder.signatureAlgorithm().matches('SHA(256|384)')).toMatchObject({ + source: 'SIGNATURE_ALGORITHM', comparison: 'MATCHES', target: 'SHA(256|384)', + }) + }) + }) + + describe('per-source builders reject operators the backend does not allow', () => { + it('rejects them at compile time', () => { + // Compile-time-only checks: the removed methods do not exist at runtime, so the + // body is type-checked but never executed (an uncalled function). + + const _typeChecks = () => { + // keySizeBits supports EQUALS only (GREATER_THAN_OR_EQUAL was dropped) + // @ts-expect-error greaterThan is not available on the key-size builder + SslAssertionBuilder.keySizeBits().greaterThan(2048) + // @ts-expect-error notEquals is not available on the key-size builder + SslAssertionBuilder.keySizeBits().notEquals(2048) + // boolean sources take a boolean, not the string 'true' + // @ts-expect-error 'true' (string) is not assignable to boolean + SslAssertionBuilder.certNotExpired().equals('true') + // matches is not offered on non-string sources + // @ts-expect-error matches is not available on the cert-not-expired builder + SslAssertionBuilder.certNotExpired().matches('x') + // the general string operators are not offered on SSL string sources + // @ts-expect-error contains is not available on the cipher-suite builder + SslAssertionBuilder.cipherSuite().contains('x') + // tlsVersion supports EQUALS only + // @ts-expect-error notEquals is not available on the tls-version builder + SslAssertionBuilder.tlsVersion().notEquals('TLS1.3') + } + expect(_typeChecks).toBeDefined() + }) + }) }) diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts index d574d438d..40ccfb2af 100644 --- a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -116,6 +116,52 @@ 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().equals(2048), + SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + SslAssertionBuilder.cipherSuite().equals('TLS_AES_256_GCM_SHA384'), + SslAssertionBuilder.issuerCn().equals('Let\'s Encrypt'), + SslAssertionBuilder.ocspStapled().equals(true), + ], + }, + }) + + const payload = check.synthesize() as any + expect(payload.request.assertions).toEqual([ + expect.objectContaining({ source: 'KEY_SIZE_BITS', comparison: 'EQUALS', target: '2048' }), + expect.objectContaining({ source: 'TLS_VERSION', comparison: 'EQUALS', target: 'TLS1.3' }), + expect.objectContaining({ source: 'CIPHER_SUITE', comparison: 'EQUALS', target: 'TLS_AES_256_GCM_SHA384' }), + expect.objectContaining({ source: 'ISSUER_CN', comparison: 'EQUALS', target: 'Let\'s Encrypt' }), + expect.objectContaining({ source: 'OCSP_STAPLED', comparison: 'EQUALS', target: 'true' }), + ]) + }) + + 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 +222,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 }) @@ -184,4 +267,82 @@ describe('SslMonitor', () => { expect(diags.isFatal()).toEqual(false) }) }) + + describe('assertion validation', () => { + async function validateWith (assertions: SslRequest['assertions']): Promise { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { hostname: 'example.com', sslConfig: {}, assertions }, + }) + const diags = new Diagnostics() + await check.validate(diags) + return diags + } + + it('should error on an assertion with an unknown source', async () => { + const diags = await validateWith([ + { source: 'HANDSHAKE_TIME_MS', property: '', comparison: 'LESS_THAN', target: '100', regex: null }, + ] as unknown as SslRequest['assertions']) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The assertion at "request.assertions[0]" has an unknown source "HANDSHAKE_TIME_MS".', + ), + }), + ])) + }) + + it('should error on a comparison the source does not allow', async () => { + const diags = await validateWith([ + // KEY_SIZE_BITS supports EQUALS only. + { source: 'KEY_SIZE_BITS', property: '', comparison: 'GREATER_THAN', target: '2048', regex: null }, + ]) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The KEY_SIZE_BITS assertion at "request.assertions[0]" has an unsupported comparison "GREATER_THAN".', + ), + }), + ])) + }) + + it('should error on MATCHES for a source that does not allow it', async () => { + const diags = await validateWith([ + { source: 'CERT_EXPIRES_IN_DAYS', property: '', comparison: 'MATCHES', target: '.*', regex: null }, + ]) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('has an unsupported comparison "MATCHES"') }), + ])) + }) + + it('should error on a boolean source with a non-boolean target', async () => { + const diags = await validateWith([ + { source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'yes', regex: null }, + ]) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The CERT_NOT_EXPIRED assertion at "request.assertions[0]" must compare against "true" or "false", but got "yes".', + ), + }), + ])) + }) + + it('should not error on assertions built with SslAssertionBuilder', async () => { + const diags = await validateWith([ + SslAssertionBuilder.certExpiresInDays().greaterThan(30), + SslAssertionBuilder.keySizeBits().equals(2048), + SslAssertionBuilder.chainTrusted().equals(true), + SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + SslAssertionBuilder.cipherSuite().matches('TLS_(AES|CHACHA)'), + SslAssertionBuilder.issuerCn().matches('^Let\'s Encrypt'), + ]) + expect(diags.isFatal()).toEqual(false) + }) + }) }) 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..a5cd613ec --- /dev/null +++ b/packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts @@ -0,0 +1,62 @@ +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 an argument', () => { + 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) + } + }) + + // Codegen runs in the decode direction: backend wire data becomes construct source. + // The backend only ever returns one of 'avg', 'min', 'max' or 'stdDev' as the property + // of a RESPONSE_TIME assertion, so this input is synthetic. The bare responseTime() it + // emits still compiles and resolves to the 'avg' default. Codegen relies on that + // guarantee: a property outside the union would emit source that does not typecheck. + // The encode direction is guarded separately by validateTracerouteAssertion, which + // rejects a hand-written empty property at deploy time. + 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 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..8c361bf6e 100644 --- a/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest' -import { TracerouteMonitor, TracerouteAssertionBuilder, CheckGroup, TracerouteRequest, Diagnostics } from '../index.js' +import { + TracerouteMonitor, + TracerouteAssertion, + TracerouteAssertionBuilder, + CheckGroup, + TracerouteRequest, + Diagnostics, +} from '../index.js' import { Project } from '../project.js' import { Session } from '../session.js' import { Bundler } from '../../services/check-parser/bundler.js' @@ -84,7 +91,59 @@ describe('TracerouteMonitor', () => { expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) }) + describe('responseTime() assertions', () => { + it('should default the property to avg', () => { + expect(TracerouteAssertionBuilder.responseTime().lessThan(1000)).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', { @@ -114,5 +173,231 @@ describe('TracerouteMonitor', () => { await check.validate(diags) expect(diags.isFatal()).toEqual(false) }) + + it('should error on a RESPONSE_TIME assertion without a property', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null }, + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The RESPONSE_TIME assertion at "request.assertions[0]" has an invalid property (none).', + ), + }), + ])) + }) + + it('should error on a RESPONSE_TIME assertion with an unknown property', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + { source: 'RESPONSE_TIME', property: 'median', comparison: 'LESS_THAN', target: '1000', regex: null }, + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Expected one of "avg", "min", "max", "stdDev".'), + }), + ])) + }) + + it('should error on a HOP_COUNT assertion with a property', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + { source: 'HOP_COUNT', property: 'avg', comparison: 'LESS_THAN', target: '20', regex: null }, + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The HOP_COUNT assertion at "request.assertions[0]" must not specify a property, but got "avg".', + ), + }), + ])) + }) + + it('should error on a PACKET_LOSS assertion with a property', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + { source: 'PACKET_LOSS', property: 'avg', comparison: 'LESS_THAN', target: '10', regex: null }, + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('The PACKET_LOSS assertion at "request.assertions[0]"'), + }), + ])) + }) + + it('should error on an assertion with an unknown source', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + // Check files are loaded without type checking, so an unrecognized source + // reaches validation at runtime despite not being part of the union. + { source: 'JITTER', property: '', comparison: 'LESS_THAN', target: '5', regex: null }, + ] as unknown as TracerouteAssertion[], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The assertion at "request.assertions[0]" has an unknown source "JITTER".', + ), + }), + ])) + }) + + it('should report the index of the offending assertion', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + TracerouteAssertionBuilder.hopCount().lessThan(20), + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null }, + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('request.assertions[1]'), + }), + ])) + }) + + it('should error on a HOP_COUNT assertion using NOT_EQUALS', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + // NOT_EQUALS is not offered on the hopCount() builder (see the compile-time + // test below); a hand-written literal can still carry it, and the backend + // accepts NOT_EQUALS only for RESPONSE_TIME. + assertions: [{ source: 'HOP_COUNT', property: '', comparison: 'NOT_EQUALS', target: '20', regex: null }], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + 'The HOP_COUNT assertion at "request.assertions[0]" has an unsupported comparison "NOT_EQUALS".', + ), + }), + ])) + }) + + it('the builders do not offer NOT_EQUALS for hop count / packet loss', () => { + // Compile-time-only checks: notEquals does not exist on these builders at + // runtime, so the body is type-checked but never executed. + + const _typeChecks = () => { + // @ts-expect-error notEquals is not available on the hop-count builder + TracerouteAssertionBuilder.hopCount().notEquals(20) + // @ts-expect-error notEquals is not available on the packet-loss builder + TracerouteAssertionBuilder.packetLoss().notEquals(20) + } + expect(_typeChecks).toBeDefined() + }) + + it('should allow NOT_EQUALS for a RESPONSE_TIME assertion', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [TracerouteAssertionBuilder.responseTime('avg').notEquals(1000)], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + + it('should error on an assertion with an unknown comparison', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + { source: 'PACKET_LOSS', property: '', comparison: 'CONTAINS', target: '10', regex: null }, + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('has an unsupported comparison "CONTAINS"'), + }), + ])) + }) + + it('should not error on assertions built with TracerouteAssertionBuilder', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + assertions: [ + TracerouteAssertionBuilder.responseTime().lessThan(1000), + TracerouteAssertionBuilder.responseTime('stdDev').lessThan(50), + TracerouteAssertionBuilder.hopCount().lessThan(20), + TracerouteAssertionBuilder.packetLoss().lessThan(10), + ], + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) }) }) diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index 23755a409..90d119d49 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -210,10 +210,10 @@ describe('SslMonitorCodegen', () => { })) expect(source).toContain('certExpiresInDays().greaterThan(20)') expect(source).toContain('keySizeBits().equals(2048)') - expect(source).toContain('certNotExpired().equals(\'true\')') - expect(source).toContain('hostnameVerified().equals(\'true\')') - expect(source).toContain('chainTrusted().equals(\'true\')') - expect(source).toContain('ocspStapled().equals(\'true\')') + expect(source).toContain('certNotExpired().equals(true)') + expect(source).toContain('hostnameVerified().equals(true)') + expect(source).toContain('chainTrusted().equals(true)') + expect(source).toContain('ocspStapled().equals(true)') expect(source).toContain('tlsVersion().equals(\'TLS1.3\')') expect(source).toContain('cipherSuite().equals(\'TLS_AES_256_GCM_SHA384\')') expect(source).toContain('signatureAlgorithm().equals(\'SHA256-RSA\')') diff --git a/packages/cli/src/constructs/dns-request.ts b/packages/cli/src/constructs/dns-request.ts index 7ed84898d..0de0cd8ab 100644 --- a/packages/cli/src/constructs/dns-request.ts +++ b/packages/cli/src/constructs/dns-request.ts @@ -52,7 +52,7 @@ export interface DnsRequest { * @minimum 1 * @maximum 65535 * @example 53 - * @default 53 + * @defaultValue 53 */ port?: number @@ -61,7 +61,7 @@ export interface DnsRequest { * * @example "UDP" * @example "TCP" - * @default "UDP" + * @defaultValue "UDP" */ protocol?: DnsProtocol diff --git a/packages/cli/src/constructs/grpc-assertion-validation.ts b/packages/cli/src/constructs/grpc-assertion-validation.ts new file mode 100644 index 000000000..1a4f1b601 --- /dev/null +++ b/packages/cli/src/constructs/grpc-assertion-validation.ts @@ -0,0 +1,65 @@ +import { Diagnostics } from './diagnostics.js' +import { GrpcAssertion } from './grpc-assertion.js' +import { addAssertionDiagnostic, quotedKeys } from './internal/assertion-validation.js' + +// Keyed by the source union so a member added to GrpcAssertion['source'] without a +// matching entry here is a compile-time error. +const assertionSources: Record = { + RESPONSE_TIME: true, + GRPC_RESPONSE: true, + TEXT_BODY: true, + GRPC_METADATA: true, + GRPC_HEALTHCHECK_STATUS: true, + GRPC_STATUS_CODE: true, +} + +// The comparisons the backend accepts for gRPC assertions. Unlike traceroute, the +// backend does not couple the comparison to the source — it is the shared +// 14-operator list. Keep in sync with the backend's accepted comparison set. +const assertionComparisons: Record = { + EQUALS: true, + NOT_EQUALS: true, + HAS_KEY: true, + NOT_HAS_KEY: true, + HAS_VALUE: true, + NOT_HAS_VALUE: true, + IS_EMPTY: true, + NOT_EMPTY: true, + GREATER_THAN: true, + LESS_THAN: true, + CONTAINS: true, + NOT_CONTAINS: true, + IS_NULL: true, + NOT_NULL: true, +} + +/** + * Reports gRPC assertions whose source or comparison the backend does not accept. + * + * gRPC places no constraint on `property` — any string is valid for every source — + * so only the source and comparison are checked. Assertions written as plain object + * literals bypass GrpcAssertionBuilder and are type-legal because the fields are + * declared as plain strings; the backend rejects an unknown source or comparison + * with a 400. Source and comparison are independent (the accepted comparison does + * not depend on the source), so both are always checked. + */ +export function validateGrpcAssertion ( + diagnostics: Diagnostics, + assertion: GrpcAssertion, + index: number, +): void { + const location = `request.assertions[${index}]` + + if (!Object.hasOwn(assertionSources, assertion.source)) { + addAssertionDiagnostic(diagnostics, + `The assertion at "${location}" has an unknown source "${assertion.source}". ` + + `Expected one of ${quotedKeys(assertionSources)}.`) + } + + if (!Object.hasOwn(assertionComparisons, assertion.comparison)) { + addAssertionDiagnostic(diagnostics, + `The assertion at "${location}" has an unsupported comparison ` + + `${assertion.comparison === '' ? '(none)' : `"${assertion.comparison}"`}. ` + + `Expected one of ${quotedKeys(assertionComparisons)}.`) + } +} diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts index e8be032e7..0cb90931a 100644 --- a/packages/cli/src/constructs/grpc-monitor.ts +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -2,6 +2,7 @@ import { Monitor, MonitorProps } from './monitor.js' import { Session } from './session.js' 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' export interface GrpcMonitorProps extends MonitorProps { @@ -14,9 +15,9 @@ export interface GrpcMonitorProps extends MonitorProps { * The response time in milliseconds where the monitor should be considered * degraded. * - * @defaultValue 4000 + * @defaultValue 10000 * @minimum 0 - * @maximum 30000 + * @maximum 180000 * @example * ```typescript * degradedResponseTime: 3000 // Alert when the gRPC call takes longer than 3 seconds @@ -28,9 +29,9 @@ export interface GrpcMonitorProps extends MonitorProps { * The response time in milliseconds where the monitor should be considered * failing. * - * @defaultValue 5000 + * @defaultValue 20000 * @minimum 0 - * @maximum 30000 + * @maximum 180000 * @example * ```typescript * maxResponseTime: 10000 // Fail if the gRPC call takes longer than 10 seconds @@ -76,9 +77,17 @@ export class GrpcMonitor extends Monitor { await super.validate(diagnostics) await validateResponseTimes(diagnostics, this, { - degradedResponseTime: 30_000, - maxResponseTime: 30_000, + // 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, + // Backend default applied when maxResponseTime is omitted. + defaultMaxResponseTime: 20_000, }) + + for (const [index, assertion] of (this.request.assertions ?? []).entries()) { + validateGrpcAssertion(diagnostics, assertion, index) + } } synthesize () { diff --git a/packages/cli/src/constructs/grpc-request.ts b/packages/cli/src/constructs/grpc-request.ts index 77557fb6d..643a574cf 100644 --- a/packages/cli/src/constructs/grpc-request.ts +++ b/packages/cli/src/constructs/grpc-request.ts @@ -41,21 +41,21 @@ export interface GrpcConfig { * `method`); `HEALTH` queries the standard gRPC health-check service (allows * `service`). * - * @default "BEHAVIOR" + * @defaultValue "BEHAVIOR" */ mode?: GrpcMode /** * Whether to use a TLS-encrypted connection to the gRPC server. * - * @default true + * @defaultValue true */ tls?: boolean /** * Whether to store the gRPC response body with the check result. * - * @default true + * @defaultValue true */ storeResponseBody?: boolean @@ -69,7 +69,7 @@ export interface GrpcConfig { * uses server reflection; `PROTO_FILE` uses the inline `protoContent`. * Forbidden in `HEALTH` mode. * - * @default "REFLECTION" + * @defaultValue "REFLECTION" */ serviceDefinition?: GrpcServiceDefinition @@ -124,14 +124,14 @@ export interface GrpcRequest { /** * The IP family to use when executing the gRPC check. * - * @default "IPv4" + * @defaultValue "IPv4" */ ipFamily?: IPFamily /** * Whether to skip SSL certificate validation when `tls` is enabled. * - * @default false + * @defaultValue false */ skipSSL?: boolean @@ -141,7 +141,7 @@ export interface GrpcRequest { * * @minimum 1 * @maximum 180 - * @default 60 + * @defaultValue 60 */ timeout?: number diff --git a/packages/cli/src/constructs/icmp-request.ts b/packages/cli/src/constructs/icmp-request.ts index c7e83f106..7b7750b78 100644 --- a/packages/cli/src/constructs/icmp-request.ts +++ b/packages/cli/src/constructs/icmp-request.ts @@ -19,7 +19,7 @@ export interface IcmpRequest { * * @example "IPv4" * @example "IPv6" - * @default "IPv4" + * @defaultValue "IPv4" */ ipFamily?: IPFamily @@ -30,7 +30,7 @@ export interface IcmpRequest { * @minimum 1 * @maximum 50 * @example 10 - * @default 10 + * @defaultValue 10 */ pingCount?: number diff --git a/packages/cli/src/constructs/internal/assertion-codegen.ts b/packages/cli/src/constructs/internal/assertion-codegen.ts index f4fe1dd70..44d82bfea 100644 --- a/packages/cli/src/constructs/internal/assertion-codegen.ts +++ b/packages/cli/src/constructs/internal/assertion-codegen.ts @@ -63,9 +63,36 @@ export function valueForNumericAssertion ( }) } +// Emits an SSL boolean-source assertion: `Builder.method().equals(true|false)`. +// The four boolean SSL sources only support EQUALS and carry no property/regex. +export function valueForBooleanAssertion ( + klass: string, + method: string, + assertion: Assertion, +): Value { + return expr(ident(klass), builder => { + builder.member(ident(method)) + builder.call(() => {}) + switch (assertion.comparison) { + case 'EQUALS': + builder.member(ident('equals')) + builder.call(builder => { + builder.boolean(assertion.target === 'true') + }) + break + default: + throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`) + } + }) +} + export interface ValueForGeneralAssertionOptions { hasProperty?: boolean hasRegex?: boolean + // When set, a `MATCHES` comparison emits `.matches(target)`. Only the SSL string + // sources (CIPHER_SUITE/ISSUER_CN/SIGNATURE_ALGORITHM) accept MATCHES; every other + // caller leaves it off, so MATCHES stays a `default: throw` for them. + hasMatches?: boolean } export function valueForGeneralAssertion ( @@ -172,6 +199,15 @@ export function valueForGeneralAssertion ( builder.empty() }) break + case 'MATCHES': + if (!options?.hasMatches) { + throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`) + } + builder.member(ident('matches')) + builder.call(builder => { + builder.string(assertion.target) + }) + break default: throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`) } diff --git a/packages/cli/src/constructs/internal/assertion-validation.ts b/packages/cli/src/constructs/internal/assertion-validation.ts new file mode 100644 index 000000000..b225584fe --- /dev/null +++ b/packages/cli/src/constructs/internal/assertion-validation.ts @@ -0,0 +1,20 @@ +import { InvalidPropertyValueDiagnostic } from '../construct-diagnostics.js' +import { Diagnostics } from '../diagnostics.js' + +// Shared helpers for the per-monitor assertion validators. Assertion fields are +// declared as plain strings, so an assertion written as an object literal bypasses +// the builder and is type-legal; these helpers report the source/property/comparison +// pairings the deploy schema would reject with a 400. + +// Formats the keys of a lookup record as a quoted, comma-separated list for an +// "Expected one of ..." message. +export function quotedKeys (values: Record): string { + return Object.keys(values).map(value => `"${value}"`).join(', ') +} + +// Reports an invalid assertion as a fatal diagnostic. The property path is the array +// field itself; the offending element index and detail go in the message, following +// the convention in agentic-check.ts. +export function addAssertionDiagnostic (diagnostics: Diagnostics, message: string): void { + diagnostics.add(new InvalidPropertyValueDiagnostic('request.assertions', new Error(message))) +} diff --git a/packages/cli/src/constructs/internal/assertion.ts b/packages/cli/src/constructs/internal/assertion.ts index c6453dcef..d5f2e62b0 100644 --- a/packages/cli/src/constructs/internal/assertion.ts +++ b/packages/cli/src/constructs/internal/assertion.ts @@ -1,19 +1,3 @@ -type Comparison = - | 'EQUALS' - | 'NOT_EQUALS' - | 'HAS_KEY' - | 'NOT_HAS_KEY' - | 'HAS_VALUE' - | 'NOT_HAS_VALUE' - | 'IS_EMPTY' - | 'NOT_EMPTY' - | 'GREATER_THAN' - | 'LESS_THAN' - | 'CONTAINS' - | 'NOT_CONTAINS' - | 'IS_NULL' - | 'NOT_NULL' - export interface Assertion { source: Source property: string @@ -22,6 +6,29 @@ export interface Assertion { regex: string | null } +// Builds an assertion payload from its parts. `comparison` is a free type parameter +// so any operator string (including SSL's `MATCHES`) is accepted without a central +// union. Empty property, stringified target, and null regex are the wire defaults. +export function toAssertion< + Source extends string, + Comparison extends string, + Target extends string | number | boolean = string, +> ( + source: Source, + comparison: Comparison, + target?: Target, + property?: string, + regex?: string | null, +): Assertion { + return { + source, + comparison, + property: property ?? '', + target: target?.toString() ?? '', + regex: regex ?? null, + } +} + export class NumericAssertionBuilder { source: Source property?: Property @@ -32,30 +39,19 @@ export class NumericAssertionBuilder { - return this._toAssertion('EQUALS', target) + return toAssertion(this.source, 'EQUALS', target, this.property) } notEquals (target: number): Assertion { - return this._toAssertion('NOT_EQUALS', target) + return toAssertion(this.source, 'NOT_EQUALS', target, this.property) } lessThan (target: number): Assertion { - return this._toAssertion('LESS_THAN', target) + return toAssertion(this.source, 'LESS_THAN', target, this.property) } greaterThan (target: number): Assertion { - return this._toAssertion('GREATER_THAN', target) - } - - /** @private */ - private _toAssertion (comparison: Comparison, target: number): Assertion { - return { - source: this.source, - comparison, - property: this.property ?? '', - target: target.toString(), - regex: null, - } + return toAssertion(this.source, 'GREATER_THAN', target, this.property) } } @@ -83,69 +79,58 @@ export class GeneralAssertionBuilder< } equals (target: TargetType): Assertion { - return this._toAssertion('EQUALS', target) + return toAssertion(this.source, 'EQUALS', target, this.property, this.regex) } notEquals (target: TargetType): Assertion { - return this._toAssertion('NOT_EQUALS', target) + return toAssertion(this.source, 'NOT_EQUALS', target, this.property, this.regex) } hasKey (target: string): Assertion { - return this._toAssertion('HAS_KEY', target) + return toAssertion(this.source, 'HAS_KEY', target, this.property, this.regex) } notHasKey (target: string): Assertion { - return this._toAssertion('NOT_HAS_KEY', target) + return toAssertion(this.source, 'NOT_HAS_KEY', target, this.property, this.regex) } hasValue (target: TargetType): Assertion { - return this._toAssertion('HAS_VALUE', target) + return toAssertion(this.source, 'HAS_VALUE', target, this.property, this.regex) } notHasValue (target: TargetType): Assertion { - return this._toAssertion('NOT_HAS_VALUE', target) + return toAssertion(this.source, 'NOT_HAS_VALUE', target, this.property, this.regex) } isEmpty () { - return this._toAssertion('IS_EMPTY') + return toAssertion(this.source, 'IS_EMPTY', undefined, this.property, this.regex) } notEmpty () { - return this._toAssertion('NOT_EMPTY') + return toAssertion(this.source, 'NOT_EMPTY', undefined, this.property, this.regex) } lessThan (target: TargetType): Assertion { - return this._toAssertion('LESS_THAN', target) + return toAssertion(this.source, 'LESS_THAN', target, this.property, this.regex) } greaterThan (target: TargetType): Assertion { - return this._toAssertion('GREATER_THAN', target) + return toAssertion(this.source, 'GREATER_THAN', target, this.property, this.regex) } contains (target: string): Assertion { - return this._toAssertion('CONTAINS', target) + return toAssertion(this.source, 'CONTAINS', target, this.property, this.regex) } notContains (target: string): Assertion { - return this._toAssertion('NOT_CONTAINS', target) + return toAssertion(this.source, 'NOT_CONTAINS', target, this.property, this.regex) } isNull () { - return this._toAssertion('IS_NULL') + return toAssertion(this.source, 'IS_NULL', undefined, this.property, this.regex) } isNotNull () { - return this._toAssertion('NOT_NULL') - } - - /** @private */ - private _toAssertion (comparison: Comparison, target?: string | number | boolean): Assertion { - return { - source: this.source, - comparison, - property: this.property ?? '', - target: target?.toString() ?? '', - regex: this.regex ?? null, - } + return toAssertion(this.source, 'NOT_NULL', undefined, this.property, this.regex) } } 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 3c1ac2f3b..92083a6fa 100644 --- a/packages/cli/src/constructs/ssl-assertion-codegen.ts +++ b/packages/cli/src/constructs/ssl-assertion-codegen.ts @@ -1,8 +1,15 @@ import { GeneratedFile, Value } from '../sourcegen/index.js' -import { unsupportedAssertionSource, valueForGeneralAssertion, valueForNumericAssertion } from './internal/assertion-codegen.js' +import { + unsupportedAssertionSource, + valueForBooleanAssertion, + valueForGeneralAssertion, + valueForNumericAssertion, +} from './internal/assertion-codegen.js' import { SslAssertion } from './ssl-assertion.js' const generalNoArgs = { hasProperty: false, hasRegex: false } +// The string sources additionally accept the MATCHES (regex) operator. +const generalWithMatches = { hasProperty: false, hasRegex: false, hasMatches: true } export function valueForSslAssertion (genfile: GeneratedFile, assertion: SslAssertion): Value { genfile.namedImport('SslAssertionBuilder', 'checkly/constructs') @@ -13,25 +20,25 @@ export function valueForSslAssertion (genfile: GeneratedFile, assertion: SslAsse case 'KEY_SIZE_BITS': return valueForNumericAssertion('SslAssertionBuilder', 'keySizeBits', assertion) case 'CERT_NOT_EXPIRED': - return valueForGeneralAssertion('SslAssertionBuilder', 'certNotExpired', assertion, generalNoArgs) + return valueForBooleanAssertion('SslAssertionBuilder', 'certNotExpired', assertion) case 'HOSTNAME_VERIFIED': - return valueForGeneralAssertion('SslAssertionBuilder', 'hostnameVerified', assertion, generalNoArgs) + return valueForBooleanAssertion('SslAssertionBuilder', 'hostnameVerified', assertion) case 'CHAIN_TRUSTED': - return valueForGeneralAssertion('SslAssertionBuilder', 'chainTrusted', assertion, generalNoArgs) + return valueForBooleanAssertion('SslAssertionBuilder', 'chainTrusted', assertion) case 'OCSP_STAPLED': - return valueForGeneralAssertion('SslAssertionBuilder', 'ocspStapled', assertion, generalNoArgs) + return valueForBooleanAssertion('SslAssertionBuilder', 'ocspStapled', assertion) case 'TLS_VERSION': return valueForGeneralAssertion('SslAssertionBuilder', 'tlsVersion', assertion, generalNoArgs) case 'CIPHER_SUITE': - return valueForGeneralAssertion('SslAssertionBuilder', 'cipherSuite', assertion, generalNoArgs) + return valueForGeneralAssertion('SslAssertionBuilder', 'cipherSuite', assertion, generalWithMatches) case 'ISSUER_CN': - return valueForGeneralAssertion('SslAssertionBuilder', 'issuerCn', assertion, generalNoArgs) + return valueForGeneralAssertion('SslAssertionBuilder', 'issuerCn', assertion, generalWithMatches) case 'CERT_FINGERPRINT_SHA256': return valueForGeneralAssertion('SslAssertionBuilder', 'certFingerprintSha256', assertion, generalNoArgs) case 'ISSUER_FINGERPRINT_SHA256': return valueForGeneralAssertion('SslAssertionBuilder', 'issuerFingerprintSha256', assertion, generalNoArgs) case 'SIGNATURE_ALGORITHM': - return valueForGeneralAssertion('SslAssertionBuilder', 'signatureAlgorithm', assertion, generalNoArgs) + return valueForGeneralAssertion('SslAssertionBuilder', 'signatureAlgorithm', assertion, generalWithMatches) default: return unsupportedAssertionSource(assertion.source, 'SSL') } diff --git a/packages/cli/src/constructs/ssl-assertion-validation.ts b/packages/cli/src/constructs/ssl-assertion-validation.ts new file mode 100644 index 000000000..d5668680f --- /dev/null +++ b/packages/cli/src/constructs/ssl-assertion-validation.ts @@ -0,0 +1,74 @@ +import { Diagnostics } from './diagnostics.js' +import { addAssertionDiagnostic, quotedKeys } from './internal/assertion-validation.js' +import { SslAssertion } from './ssl-assertion.js' + +type SslSourceRule = { + comparisons: Record + // Boolean sources compare against 'true'/'false' only; the backend evaluates any + // other target as a permanent non-match. Target *values* are validated only for + // boolean sources: booleans are a universal two-value set, so a typo is a pure + // footgun. The larger closed sets (TLS_VERSION, SIGNATURE_ALGORITHM) are constrained + // by the builder's value types instead; the backend accepts any target string (it is + // not rejected at deploy), so a hand-written out-of-set literal simply never matches + // at evaluation, and re-validating those here would duplicate the value unions. + booleanTarget?: boolean +} + +// The operators the backend accepts per source, minus the CLI-dropped +// GREATER_THAN_OR_EQUAL. Keyed by the source union so adding a source without a rule +// fails to compile. +const rules: Record = { + CERT_EXPIRES_IN_DAYS: { comparisons: { EQUALS: true, NOT_EQUALS: true, GREATER_THAN: true, LESS_THAN: true } }, + KEY_SIZE_BITS: { comparisons: { EQUALS: true } }, + CERT_NOT_EXPIRED: { comparisons: { EQUALS: true }, booleanTarget: true }, + HOSTNAME_VERIFIED: { comparisons: { EQUALS: true }, booleanTarget: true }, + CHAIN_TRUSTED: { comparisons: { EQUALS: true }, booleanTarget: true }, + OCSP_STAPLED: { comparisons: { EQUALS: true }, booleanTarget: true }, + TLS_VERSION: { comparisons: { EQUALS: true } }, + SIGNATURE_ALGORITHM: { comparisons: { EQUALS: true, MATCHES: true } }, + CIPHER_SUITE: { comparisons: { EQUALS: true, NOT_EQUALS: true, MATCHES: true } }, + ISSUER_CN: { comparisons: { EQUALS: true, NOT_EQUALS: true, MATCHES: true } }, + CERT_FINGERPRINT_SHA256: { comparisons: { EQUALS: true } }, + ISSUER_FINGERPRINT_SHA256: { comparisons: { EQUALS: true } }, +} + +/** + * Reports SSL assertions whose source, comparison or boolean target the backend does + * not accept. + * + * Assertions written as plain object literals bypass SslAssertionBuilder and are + * type-legal, because the fields are declared as plain strings. The backend rejects + * an unknown source or an operator not allowed for the source with a 400; a boolean + * source with a non-`true`/`false` target is accepted at deploy but never matches at + * evaluation, so it is reported too. + */ +export function validateSslAssertion ( + diagnostics: Diagnostics, + assertion: SslAssertion, + index: number, +): void { + const location = `request.assertions[${index}]` + + const rule = Object.hasOwn(rules, assertion.source) + ? rules[assertion.source as SslAssertion['source']] + : undefined + if (rule === undefined) { + addAssertionDiagnostic(diagnostics, + `The assertion at "${location}" has an unknown source "${assertion.source}". ` + + `Expected one of ${quotedKeys(rules)}.`) + return + } + + if (!Object.hasOwn(rule.comparisons, assertion.comparison)) { + addAssertionDiagnostic(diagnostics, + `The ${assertion.source} assertion at "${location}" has an unsupported comparison ` + + `${assertion.comparison === '' ? '(none)' : `"${assertion.comparison}"`}. ` + + `Expected one of ${quotedKeys(rule.comparisons)}.`) + } + + if (rule.booleanTarget && assertion.target !== 'true' && assertion.target !== 'false') { + addAssertionDiagnostic(diagnostics, + `The ${assertion.source} assertion at "${location}" must compare against "true" or "false", ` + + `but got "${assertion.target}".`) + } +} diff --git a/packages/cli/src/constructs/ssl-assertion.ts b/packages/cli/src/constructs/ssl-assertion.ts index 337b6c73e..5752c08cd 100644 --- a/packages/cli/src/constructs/ssl-assertion.ts +++ b/packages/cli/src/constructs/ssl-assertion.ts @@ -1,4 +1,4 @@ -import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion.js' +import { Assertion as CoreAssertion, toAssertion } from './internal/assertion.js' /** * Known TLS protocol versions for use with {@link SslAssertionBuilder.tlsVersion}. @@ -102,6 +102,130 @@ type SslAssertionSource = export type SslAssertion = CoreAssertion +// One builder class per SSL source, each exposing only the operators (and value +// type) the backend accepts for that source. The classes are stateless — the source +// is baked into each `toAssertion` call — and are not exported: they are reachable +// only through `SslAssertionBuilder`, so they stay out of the package's public API. + +/** Days until the certificate expires (numeric). */ +class CertExpiresInDaysAssertionBuilder { + equals (target: number): SslAssertion { + return toAssertion('CERT_EXPIRES_IN_DAYS', 'EQUALS', target) + } + + notEquals (target: number): SslAssertion { + return toAssertion('CERT_EXPIRES_IN_DAYS', 'NOT_EQUALS', target) + } + + lessThan (target: number): SslAssertion { + return toAssertion('CERT_EXPIRES_IN_DAYS', 'LESS_THAN', target) + } + + greaterThan (target: number): SslAssertion { + return toAssertion('CERT_EXPIRES_IN_DAYS', 'GREATER_THAN', target) + } +} + +/** Certificate key size in bits (numeric, exact match only). */ +class KeySizeBitsAssertionBuilder { + equals (target: number): SslAssertion { + return toAssertion('KEY_SIZE_BITS', 'EQUALS', target) + } +} + +/** Whether the certificate is not expired (boolean). */ +class CertNotExpiredAssertionBuilder { + equals (target: boolean): SslAssertion { + return toAssertion('CERT_NOT_EXPIRED', 'EQUALS', target) + } +} + +/** Whether the hostname is verified (boolean). */ +class HostnameVerifiedAssertionBuilder { + equals (target: boolean): SslAssertion { + return toAssertion('HOSTNAME_VERIFIED', 'EQUALS', target) + } +} + +/** Whether the certificate chain is trusted (boolean). */ +class ChainTrustedAssertionBuilder { + equals (target: boolean): SslAssertion { + return toAssertion('CHAIN_TRUSTED', 'EQUALS', target) + } +} + +/** Whether a stapled OCSP response was provided during the handshake (boolean). */ +class OcspStapledAssertionBuilder { + equals (target: boolean): SslAssertion { + return toAssertion('OCSP_STAPLED', 'EQUALS', target) + } +} + +/** Negotiated TLS version — `.equals()` accepts only known TLS version strings. */ +class TlsVersionAssertionBuilder { + equals (target: TlsVersionValue): SslAssertion { + return toAssertion('TLS_VERSION', 'EQUALS', target) + } +} + +/** + * Certificate signature algorithm. `.equals()` takes a Go + * `x509.Certificate.SignatureAlgorithm.String()` value; `.matches()` takes a regex. + */ +class SignatureAlgorithmAssertionBuilder { + equals (target: SignatureAlgorithmValue): SslAssertion { + return toAssertion('SIGNATURE_ALGORITHM', 'EQUALS', target) + } + + matches (target: string): SslAssertion { + return toAssertion('SIGNATURE_ALGORITHM', 'MATCHES', target) + } +} + +/** Negotiated cipher suite (string) — exact, not-equal, or regex match. */ +class CipherSuiteAssertionBuilder { + equals (target: string): SslAssertion { + return toAssertion('CIPHER_SUITE', 'EQUALS', target) + } + + notEquals (target: string): SslAssertion { + return toAssertion('CIPHER_SUITE', 'NOT_EQUALS', target) + } + + matches (target: string): SslAssertion { + return toAssertion('CIPHER_SUITE', 'MATCHES', target) + } +} + +/** Certificate issuer common name (string) — exact, not-equal, or regex match. */ +class IssuerCnAssertionBuilder { + equals (target: string): SslAssertion { + return toAssertion('ISSUER_CN', 'EQUALS', target) + } + + notEquals (target: string): SslAssertion { + return toAssertion('ISSUER_CN', 'NOT_EQUALS', target) + } + + matches (target: string): SslAssertion { + return toAssertion('ISSUER_CN', 'MATCHES', target) + } +} + +/** Certificate SHA-256 fingerprint (string, exact match only). */ +class CertFingerprintSha256AssertionBuilder { + equals (target: string): SslAssertion { + return toAssertion('CERT_FINGERPRINT_SHA256', 'EQUALS', target) + } +} + +/** Issuer SHA-256 fingerprint (string, exact match only). */ +class IssuerFingerprintSha256AssertionBuilder { + equals (target: string): SslAssertion { + return toAssertion('ISSUER_FINGERPRINT_SHA256', 'EQUALS', target) + } +} + /** * Builder class for creating SSL monitor assertions. * Provides methods to create assertions for TLS certificates. @@ -115,117 +239,86 @@ export type SslAssertion = CoreAssertion * SslAssertionBuilder.chainTrusted().equals(true) * SslAssertionBuilder.hostnameVerified().equals(true) * - * // Enforce a minimum TLS version and key size + * // Enforce a specific TLS version and key size * SslAssertionBuilder.tlsVersion().equals('TLS1.3') - * SslAssertionBuilder.keySizeBits().greaterThan(2048) + * SslAssertionBuilder.keySizeBits().equals(2048) + * + * // Match the issuer or cipher suite against a regex + * SslAssertionBuilder.issuerCn().matches("^Let's Encrypt") + * SslAssertionBuilder.cipherSuite().matches('TLS_(AES|CHACHA)') * ``` */ export class SslAssertionBuilder { - /** - * Creates an assertion builder for the number of days until the certificate - * expires. - * @returns A numeric assertion builder for days until expiry. - */ + /** Assertion builder for the number of days until the certificate expires. */ static certExpiresInDays () { - return new NumericAssertionBuilder('CERT_EXPIRES_IN_DAYS') + return new CertExpiresInDaysAssertionBuilder() } - /** - * Creates an assertion builder for the certificate key size in bits. - * @returns A numeric assertion builder for the key size. - */ + /** Assertion builder for the certificate key size in bits. */ static keySizeBits () { - return new NumericAssertionBuilder('KEY_SIZE_BITS') + return new KeySizeBitsAssertionBuilder() } - /** - * Creates an assertion builder for whether the certificate is not expired. - * @returns A general assertion builder for the expiry status. - */ + /** Assertion builder for whether the certificate is not expired. */ static certNotExpired () { - return new GeneralAssertionBuilder('CERT_NOT_EXPIRED') + return new CertNotExpiredAssertionBuilder() } - /** - * Creates an assertion builder for whether the hostname is verified. - * @returns A general assertion builder for the hostname verification status. - */ + /** Assertion builder for whether the hostname is verified. */ static hostnameVerified () { - return new GeneralAssertionBuilder('HOSTNAME_VERIFIED') + return new HostnameVerifiedAssertionBuilder() } - /** - * Creates an assertion builder for whether the certificate chain is trusted. - * @returns A general assertion builder for the chain trust status. - */ + /** Assertion builder for whether the certificate chain is trusted. */ static chainTrusted () { - return new GeneralAssertionBuilder('CHAIN_TRUSTED') + return new ChainTrustedAssertionBuilder() } /** - * Creates an assertion builder for the negotiated TLS version. - * The `.equals()` method accepts only the - * known TLS version strings — use the {@link TlsVersion} constants or - * the string literals `'TLS1.0'`…`'TLS1.3'`. - * @returns A typed assertion builder for the TLS version. + * Assertion builder for the negotiated TLS version. `.equals()` accepts only the + * known TLS version strings — use the {@link TlsVersion} constants or the string + * literals `'TLS1.0'`…`'TLS1.3'`. */ - static tlsVersion (): GeneralAssertionBuilder { - return new GeneralAssertionBuilder('TLS_VERSION') + static tlsVersion () { + return new TlsVersionAssertionBuilder() } /** - * Creates an assertion builder for the negotiated cipher suite. - * Go's `tls.CipherSuiteName()` can return hundreds of IANA names plus - * `0x....` hex fallbacks, so this builder is intentionally unconstrained. - * Use the {@link CipherSuite} constants for common suites (autocomplete), - * or pass any string literal / regex pattern as needed. - * @returns An unconstrained assertion builder for the cipher suite. + * Assertion builder for the negotiated cipher suite. Go's `tls.CipherSuiteName()` + * can return hundreds of IANA names plus `0x....` hex fallbacks, so `.equals()` is + * unconstrained; use the {@link CipherSuite} constants for common suites, or + * `.matches()` with a regex pattern. */ - static cipherSuite (): GeneralAssertionBuilder { - return new GeneralAssertionBuilder('CIPHER_SUITE') + static cipherSuite () { + return new CipherSuiteAssertionBuilder() } - /** - * Creates an assertion builder for the certificate issuer common name. - * @returns A general assertion builder for the issuer CN. - */ + /** Assertion builder for the certificate issuer common name. */ static issuerCn () { - return new GeneralAssertionBuilder('ISSUER_CN') + return new IssuerCnAssertionBuilder() } - /** - * Creates an assertion builder for the certificate SHA-256 fingerprint. - * @returns A general assertion builder for the certificate fingerprint. - */ + /** Assertion builder for the certificate SHA-256 fingerprint. */ static certFingerprintSha256 () { - return new GeneralAssertionBuilder('CERT_FINGERPRINT_SHA256') + return new CertFingerprintSha256AssertionBuilder() } - /** - * Creates an assertion builder for the issuer SHA-256 fingerprint. - * @returns A general assertion builder for the issuer fingerprint. - */ + /** Assertion builder for the issuer SHA-256 fingerprint. */ static issuerFingerprintSha256 () { - return new GeneralAssertionBuilder('ISSUER_FINGERPRINT_SHA256') + return new IssuerFingerprintSha256AssertionBuilder() } /** - * Creates an assertion builder for the certificate signature algorithm. - * Values are Go's `x509.Certificate.SignatureAlgorithm.String()` output - * (e.g. `'SHA256-RSA'`, `'ECDSA-SHA256'`). Use the {@link SignatureAlgorithm} - * constants or those string literals with `.equals()`. - * @returns A typed assertion builder for the signature algorithm. + * Assertion builder for the certificate signature algorithm. `.equals()` takes a + * Go `x509.Certificate.SignatureAlgorithm.String()` value (e.g. `'SHA256-RSA'`) — + * use the {@link SignatureAlgorithm} constants — or `.matches()` with a regex. */ - static signatureAlgorithm (): GeneralAssertionBuilder { - return new GeneralAssertionBuilder('SIGNATURE_ALGORITHM') + static signatureAlgorithm () { + return new SignatureAlgorithmAssertionBuilder() } - /** - * 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. - */ + /** Assertion builder for whether a stapled OCSP response was provided. */ static ocspStapled () { - return new GeneralAssertionBuilder('OCSP_STAPLED') + return new OcspStapledAssertionBuilder() } } diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index 38b4c6321..438755b7a 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -2,7 +2,9 @@ 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 { validateSslAssertion } from './ssl-assertion-validation.js' +import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' export interface SslMonitorProps extends MonitorProps { /** @@ -16,7 +18,7 @@ export interface SslMonitorProps extends MonitorProps { * * @minimum 0 * @maximum 30000 - * @default 3000 + * @defaultValue 3000 */ degradedResponseTime?: number @@ -26,7 +28,7 @@ export interface SslMonitorProps extends MonitorProps { * * @minimum 0 * @maximum 30000 - * @default 10000 + * @defaultValue 10000 */ maxResponseTime?: number } @@ -78,17 +80,15 @@ 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. + defaultMaxResponseTime: 10_000, + }) + + for (const [index, assertion] of (this.request.assertions ?? []).entries()) { + validateSslAssertion(diagnostics, assertion, index) } } diff --git a/packages/cli/src/constructs/ssl-request.ts b/packages/cli/src/constructs/ssl-request.ts index e9ca3791f..1c451ea36 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`). @@ -89,7 +89,7 @@ export interface SslConfig { * When true, the certificate chain is not validated against trusted roots * (the certificate is still inspected for expiry and the security baseline). * - * @default false + * @defaultValue false */ skipChainValidation?: boolean @@ -99,7 +99,7 @@ export interface SslConfig { * * @minimum 1000 * @maximum 30000 - * @default 10000 + * @defaultValue 10000 */ handshakeTimeout?: number @@ -108,7 +108,7 @@ export interface SslConfig { * * @minimum 1 * @maximum 365 - * @default 20 + * @defaultValue 20 */ alertDaysBeforeExpiry?: number @@ -142,14 +142,14 @@ export interface SslRequest { * * @minimum 1 * @maximum 65535 - * @default 443 + * @defaultValue 443 */ port?: number /** * The IP family to use when executing the check. * - * @default "IPv4" + * @defaultValue "IPv4" */ ipFamily?: IPFamily diff --git a/packages/cli/src/constructs/traceroute-assertion-codegen.ts b/packages/cli/src/constructs/traceroute-assertion-codegen.ts index 50978e478..d05554a66 100644 --- a/packages/cli/src/constructs/traceroute-assertion-codegen.ts +++ b/packages/cli/src/constructs/traceroute-assertion-codegen.ts @@ -7,7 +7,9 @@ export function valueForTracerouteAssertion (genfile: GeneratedFile, assertion: switch (assertion.source) { case 'RESPONSE_TIME': - return valueForNumericAssertion('TracerouteAssertionBuilder', 'responseTime', assertion) + return valueForNumericAssertion('TracerouteAssertionBuilder', 'responseTime', assertion, { + hasProperty: true, + }) case 'HOP_COUNT': return valueForNumericAssertion('TracerouteAssertionBuilder', 'hopCount', assertion) case 'PACKET_LOSS': diff --git a/packages/cli/src/constructs/traceroute-assertion-validation.ts b/packages/cli/src/constructs/traceroute-assertion-validation.ts new file mode 100644 index 000000000..7ab1c7cc0 --- /dev/null +++ b/packages/cli/src/constructs/traceroute-assertion-validation.ts @@ -0,0 +1,98 @@ +import { Diagnostics } from './diagnostics.js' +import { addAssertionDiagnostic, quotedKeys } from './internal/assertion-validation.js' +import { TracerouteAssertion, TracerouteResponseTimeProperty } from './traceroute-assertion.js' + +// Runtime counterparts of unions that exist only at compile time. Typing them as a +// Record keyed by the union makes a missing or misspelled entry a compile-time error, +// so neither list can drift from the union it mirrors. +const responseTimeProperties: Record = { + avg: true, + min: true, + max: true, + stdDev: true, +} + +const assertionSources: Record = { + RESPONSE_TIME: true, + HOP_COUNT: true, + PACKET_LOSS: true, +} + +// Comparisons the backend accepts per source. RESPONSE_TIME additionally permits +// NOT_EQUALS; hop count and packet loss do not. Keep in sync with the backend. +const responseTimeComparisons: Record = { + EQUALS: true, + NOT_EQUALS: true, + GREATER_THAN: true, + LESS_THAN: true, +} + +const numericComparisons: Record = { + EQUALS: true, + GREATER_THAN: true, + LESS_THAN: true, +} + +/** + * Reports traceroute assertions whose source, property or comparison the backend + * does not accept. + * + * Assertions written as plain object literals bypass TracerouteAssertionBuilder and + * are type-legal, because the fields are declared as plain strings. The backend + * rejects the invalid combinations with a 400, so they are caught here instead. + */ +export function validateTracerouteAssertion ( + diagnostics: Diagnostics, + assertion: TracerouteAssertion, + index: number, +): void { + const location = `request.assertions[${index}]` + + switch (assertion.source) { + case 'RESPONSE_TIME': + if (!Object.hasOwn(responseTimeProperties, assertion.property)) { + addAssertionDiagnostic(diagnostics, + `The RESPONSE_TIME assertion at "${location}" has an invalid property ` + + `${assertion.property === '' ? '(none)' : `"${assertion.property}"`}. ` + + `Expected one of ${quotedKeys(responseTimeProperties)}.`) + } + validateComparison(diagnostics, assertion, location, responseTimeComparisons) + break + case 'HOP_COUNT': + // falls through + case 'PACKET_LOSS': + if (assertion.property) { + addAssertionDiagnostic(diagnostics, + `The ${assertion.source} assertion at "${location}" must not specify a property, ` + + `but got "${assertion.property}".`) + } + validateComparison(diagnostics, assertion, location, numericComparisons) + break + default: + addAssertionDiagnostic(diagnostics, + `The assertion at "${location}" has an unknown source "${assertion.source}". ` + + `Expected one of ${quotedKeys(assertionSources)}.`) + // Check files are loaded without type checking, so an unrecognized source reaches + // this branch at runtime and is reported above. This additionally makes adding a + // member to TracerouteAssertionSource without a matching case a compile-time error. + assertion.source satisfies never + break + } +} + +// The comparison the backend accepts depends on the source, so the allowed set is +// passed in; an unknown source is reported separately and skips this check because +// no set applies to it. +function validateComparison ( + diagnostics: Diagnostics, + assertion: TracerouteAssertion, + location: string, + allowed: Record, +): void { + if (!Object.hasOwn(allowed, assertion.comparison)) { + addAssertionDiagnostic(diagnostics, + `The ${assertion.source} assertion at "${location}" has an unsupported comparison ` + + `${assertion.comparison === '' ? '(none)' : `"${assertion.comparison}"`}. ` + + `Expected one of ${quotedKeys(allowed)}.`) + } +} diff --git a/packages/cli/src/constructs/traceroute-assertion.ts b/packages/cli/src/constructs/traceroute-assertion.ts index c625559db..e3e723686 100644 --- a/packages/cli/src/constructs/traceroute-assertion.ts +++ b/packages/cli/src/constructs/traceroute-assertion.ts @@ -1,4 +1,4 @@ -import { Assertion as CoreAssertion, NumericAssertionBuilder } from './internal/assertion.js' +import { Assertion as CoreAssertion, toAssertion } from './internal/assertion.js' type TracerouteAssertionSource = | 'RESPONSE_TIME' @@ -7,6 +7,63 @@ type TracerouteAssertionSource = export type TracerouteAssertion = CoreAssertion +export type TracerouteResponseTimeProperty = 'avg' | 'min' | 'max' | 'stdDev' + +// One builder class per source, each exposing only the operators the backend accepts +// for that source. The classes are stateless — the source (and, for response time, the +// statistical property) is baked into each `toAssertion` call — and are not exported: +// they are reachable only through `TracerouteAssertionBuilder`. + +/** Response time for a statistical property — accepts the full numeric operator set. */ +class ResponseTimeAssertionBuilder { + constructor (private property: TracerouteResponseTimeProperty) {} + equals (target: number): TracerouteAssertion { + return toAssertion('RESPONSE_TIME', 'EQUALS', target, this.property) + } + + notEquals (target: number): TracerouteAssertion { + return toAssertion('RESPONSE_TIME', 'NOT_EQUALS', target, this.property) + } + + lessThan (target: number): TracerouteAssertion { + return toAssertion('RESPONSE_TIME', 'LESS_THAN', target, this.property) + } + + greaterThan (target: number): TracerouteAssertion { + return toAssertion('RESPONSE_TIME', 'GREATER_THAN', target, this.property) + } +} + +/** Number of network hops — accepts EQUALS / LESS_THAN / GREATER_THAN (not NOT_EQUALS). */ +class HopCountAssertionBuilder { + equals (target: number): TracerouteAssertion { + return toAssertion('HOP_COUNT', 'EQUALS', target) + } + + lessThan (target: number): TracerouteAssertion { + return toAssertion('HOP_COUNT', 'LESS_THAN', target) + } + + greaterThan (target: number): TracerouteAssertion { + return toAssertion('HOP_COUNT', 'GREATER_THAN', target) + } +} + +/** Packet loss percentage (0-100) — accepts EQUALS / LESS_THAN / GREATER_THAN (not NOT_EQUALS). */ +class PacketLossAssertionBuilder { + equals (target: number): TracerouteAssertion { + return toAssertion('PACKET_LOSS', 'EQUALS', target) + } + + lessThan (target: number): TracerouteAssertion { + return toAssertion('PACKET_LOSS', 'LESS_THAN', target) + } + + greaterThan (target: number): TracerouteAssertion { + return toAssertion('PACKET_LOSS', 'GREATER_THAN', target) + } +} + /** * Builder class for creating traceroute monitor assertions. * Provides methods to create assertions for traceroute probe responses. @@ -15,6 +72,9 @@ export type TracerouteAssertion = CoreAssertion * ```typescript * // Response time assertions * TracerouteAssertionBuilder.responseTime().lessThan(1000) + * TracerouteAssertionBuilder.responseTime('avg').lessThan(1000) + * TracerouteAssertionBuilder.responseTime('max').lessThan(2000) + * TracerouteAssertionBuilder.responseTime('stdDev').lessThan(50) * * // Hop count assertions * TracerouteAssertionBuilder.hopCount().lessThan(20) @@ -25,11 +85,16 @@ 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 metrics. + * @param property The response time property to assert against. Defaults to `avg`. + * - `avg`: Average round-trip time in milliseconds + * - `min`: Minimum round-trip time in milliseconds + * - `max`: Maximum round-trip time in milliseconds + * - `stdDev`: Standard deviation of round-trip times + * @returns A numeric assertion builder for the specified response time metric. */ - static responseTime () { - return new NumericAssertionBuilder('RESPONSE_TIME') + static responseTime (property: TracerouteResponseTimeProperty = 'avg') { + return new ResponseTimeAssertionBuilder(property) } /** @@ -37,7 +102,7 @@ export class TracerouteAssertionBuilder { * @returns A numeric assertion builder for the hop count. */ static hopCount () { - return new NumericAssertionBuilder('HOP_COUNT') + return new HopCountAssertionBuilder() } /** @@ -45,6 +110,6 @@ export class TracerouteAssertionBuilder { * @returns A numeric assertion builder for packet loss. */ static packetLoss () { - return new NumericAssertionBuilder('PACKET_LOSS') + return new PacketLossAssertionBuilder() } } diff --git a/packages/cli/src/constructs/traceroute-monitor.ts b/packages/cli/src/constructs/traceroute-monitor.ts index a38345cbc..8c8fda577 100644 --- a/packages/cli/src/constructs/traceroute-monitor.ts +++ b/packages/cli/src/constructs/traceroute-monitor.ts @@ -2,6 +2,7 @@ import { Monitor, MonitorProps } from './monitor.js' import { Session } from './session.js' 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' export interface TracerouteMonitorProps extends MonitorProps { @@ -78,7 +79,13 @@ export class TracerouteMonitor extends Monitor { await validateResponseTimes(diagnostics, this, { degradedResponseTime: 30_000, maxResponseTime: 30_000, + // Backend default applied when maxResponseTime is omitted. + defaultMaxResponseTime: 20_000, }) + + for (const [index, assertion] of (this.request.assertions ?? []).entries()) { + validateTracerouteAssertion(diagnostics, assertion, index) + } } synthesize () { diff --git a/packages/cli/src/constructs/traceroute-request.ts b/packages/cli/src/constructs/traceroute-request.ts index 8c9a59bc2..c86c09058 100644 --- a/packages/cli/src/constructs/traceroute-request.ts +++ b/packages/cli/src/constructs/traceroute-request.ts @@ -31,7 +31,7 @@ export interface TracerouteRequest { /** * The probe protocol. * - * @default "TCP" + * @defaultValue "TCP" */ protocol?: TracerouteProtocol @@ -41,14 +41,14 @@ export interface TracerouteRequest { * * @minimum 1 * @maximum 65535 - * @default 443 + * @defaultValue 443 */ port?: number /** * The IP family to use when executing the traceroute. * - * @default "IPv4" + * @defaultValue "IPv4" */ ipFamily?: IPFamily @@ -57,7 +57,7 @@ export interface TracerouteRequest { * * @minimum 1 * @maximum 64 - * @default 30 + * @defaultValue 30 */ maxHops?: number @@ -67,14 +67,14 @@ export interface TracerouteRequest { * * @minimum 1 * @maximum 30 - * @default 15 + * @defaultValue 15 */ maxUnknownHops?: number /** * Whether to perform reverse-DNS (PTR) lookups on each hop's IP address. * - * @default true + * @defaultValue true */ ptrLookup?: boolean @@ -84,7 +84,7 @@ export interface TracerouteRequest { * * @minimum 1 * @maximum 30 - * @default 10 + * @defaultValue 10 */ timeout?: number diff --git a/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts index 420a24e74..4cdf0944b 100644 --- a/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts +++ b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts @@ -576,7 +576,12 @@ export const tracerouteCheckResultDetail: TracerouteCheckResult = { totalHops: 30, destinationReached: false, finalHopLatency: { avg_ms: 24.1, best_ms: 22.0, worst_ms: 31.4 }, + timingPhases: { dns: 1.5 }, requestError: null, + assertions: [ + { order: 0, source: 'LATENCY', property: 'avg', comparison: 'LESS_THAN', target: '20', regex: null, error: 'Expected 24.1 to be below 20', actual: 24.1 }, + { order: 1, source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '5000', regex: null, error: null, actual: 1000 }, + ], response: { hostname: 'unreachable.example.com', resolvedIp: '203.0.113.10', @@ -584,6 +589,7 @@ export const tracerouteCheckResultDetail: TracerouteCheckResult = { destinationReached: false, truncationReason: 'max-hops', protocol: 'TCP', + probeProtocol: 'ICMP', finalHopLatency: { avg_ms: 24.1, best_ms: 22.0, worst_ms: 31.4 }, hops: [ { hop_number: 1, main_ip: '10.0.0.1', main_host: 'gateway.local', loss_percentage: 0, rtt: { avg: 1.2, best: 1.0, worst: 1.5 } }, @@ -607,6 +613,11 @@ export const grpcCheckResultDetail: GrpcCheckResult = { grpcStatusCode: 14, healthStatus: 2, requestError: null, + timingPhases: { dns: 5, connect: 40, total: 90 }, + assertions: [ + { order: 0, source: 'GRPC_STATUS_CODE', property: '', comparison: 'EQUALS', target: '0', regex: null, error: 'Expected 14 to equal 0', actual: 14 }, + { order: 1, source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: 'NOT_SERVING', regex: null, error: null, actual: 'NOT_SERVING' }, + ], response: { grpcMode: 'HEALTH', host: 'grpc.example.com', @@ -645,6 +656,10 @@ export const sslCheckResultDetail: SslCheckResult = { baselineGrade: 'C', failureCategory: 'expired', requestError: null, + assertions: [ + { order: 0, source: 'CERT_EXPIRES_IN_DAYS', property: '', comparison: 'GREATER_THAN', target: '99999', regex: null, error: 'Expected 52.000000 to be above than 99999', actual: 52 }, + { order: 1, source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'true', regex: null, error: null, actual: true }, + ], response: { resolvedIp: '203.0.113.30', protocol: 'TLS 1.3', @@ -654,6 +669,35 @@ export const sslCheckResultDetail: SslCheckResult = { chainTrusted: false, daysUntilExpiry: -5, ocspStapled: false, + certificate: { + subject: 'CN=expired.example.com', + issuer: 'CN=Example Issuing CA,O=Example Corp,C=US', + subjectCN: 'expired.example.com', + issuerCN: 'Example Issuing CA', + serialNumber: '35428337808578903465180920265426569102', + notBefore: '2026-01-01T00:00:00Z', + notAfter: '2026-07-01T00:00:00Z', + sans: ['expired.example.com', 'www.expired.example.com'], + fingerprintSha256: 'beab14cf39678fda0ef1606eedb818c2298ba2cc7a00886e7dc2d2410f24cd35', + signatureAlgorithm: 'ECDSA-SHA256', + keyAlgorithm: 'ECDSA', + keySizeBits: 256, + selfSigned: false, + isCA: false, + }, + securityBaseline: { + verdict: 'fail', + grade: 'C', + minTLSVersion: { violated: false, severity: 'fail' }, + minKeySizeBits: { violated: false, severity: 'fail' }, + weakSignatureAlgorithm: { violated: false, severity: 'fail' }, + weakCipherSuite: { violated: true, severity: 'fail' }, + knownBadCA: { violated: false, severity: 'fail' }, + recommendedTLSVersion: { violated: false, severity: 'ignore' }, + recommendedKeySizeBits: { violated: false, severity: 'ignore' }, + ocspMustStapleRespected: { violated: false, severity: 'ignore' }, + sctPresent: { violated: false, severity: 'ignore' }, + }, }, } diff --git a/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap b/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap index 013f19e03..9ce5f473c 100644 --- a/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap +++ b/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap @@ -303,7 +303,33 @@ exports[`formatResultDetail > SSL check result > renders markdown snapshot > ssl - **Chain trusted:** no - **Hostname verified:** no - **Baseline:** FAIL grade C -- **Failure:** expired" +- **Failure:** expired + +## Certificate +- **Subject CN:** expired.example.com +- **Issuer CN:** Example Issuing CA +- **Valid:** 2026-01-01T00:00:00Z → 2026-07-01T00:00:00Z +- **Key:** ECDSA 256-bit +- **Signature:** ECDSA-SHA256 +- **SHA-256:** \`beab14cf39678fda0ef1606eedb818c2298ba2cc7a00886e7dc2d2410f24cd35\` +- **SANs:** expired.example.com, www.expired.example.com +- **Serial:** 35428337808578903465180920265426569102 +- **OCSP stapled:** no + +## Security Baseline +- ✔ min TLS version (fail) +- ✔ min key size (fail) +- ✔ weak signature algorithm (fail) +- ✖ weak cipher suite (fail) +- ✔ known bad CA (fail) +- ✔ recommended TLS version (ignore) +- ✔ recommended key size (ignore) +- ✔ OCSP must-staple respected (ignore) +- ✔ SCT present (ignore) + +## Assertions +- ✖ CERT_EXPIRES_IN_DAYS is greater than target "99999". Received: 52. +- ✔ CERT_NOT_EXPIRED equals target "true". Received: true." `; exports[`formatResultDetail > SSL check result > renders terminal snapshot > ssl-result-detail-terminal 1`] = ` @@ -326,7 +352,33 @@ Handshake: 48ms Chain trusted: no Hostname: no Baseline: FAIL grade C -Failure: expired" +Failure: expired + +CERTIFICATE +Subject CN: expired.example.com +Issuer CN: Example Issuing CA +Valid: 2026-01-01T00:00:00Z → 2026-07-01T00:00:00Z +Key: ECDSA 256-bit +Signature: ECDSA-SHA256 +SHA-256: beab14cf39678fda0ef1606eedb818c2298ba2cc7a00886e7dc2d2410f24cd35 +SANs: expired.example.com, www.expired.example.com +Serial: 35428337808578903465180920265426569102 +OCSP stapled: no + +SECURITY BASELINE + ✔ min TLS version (fail) + ✔ min key size (fail) + ✔ weak signature algorithm (fail) + ✖ weak cipher suite (fail) + ✔ known bad CA (fail) + ✔ recommended TLS version (ignore) + ✔ recommended key size (ignore) + ✔ OCSP must-staple respected (ignore) + ✔ SCT present (ignore) + +ASSERTIONS + ✖ CERT_EXPIRES_IN_DAYS is greater than target "99999". Received: 52. + ✔ CERT_NOT_EXPIRED equals target "true". Received: true." `; exports[`formatResultDetail > Traceroute check result > renders markdown snapshot > traceroute-result-detail-md 1`] = ` @@ -345,10 +397,18 @@ exports[`formatResultDetail > Traceroute check result > renders markdown snapsho ## Traceroute Result - **Destination:** unreachable.example.com (203.0.113.10) +- **Probe protocol:** ICMP - **Hops:** 30 - **Reached:** no - **Truncated:** max-hops -- **Final hop:** avg 24ms / best 22ms / worst 31ms" +- **Final hop:** avg 24ms / best 22ms / worst 31ms + +## Timing +- **DNS:** 2ms + +## Assertions +- ✖ latency property "avg" is less than target "20". Received: 24.1. +- ✔ response time is less than target "5000". Received: 1000." `; exports[`formatResultDetail > Traceroute check result > renders terminal snapshot > traceroute-result-detail-terminal 1`] = ` @@ -366,6 +426,7 @@ ID: result-tr-1 TRACEROUTE RESULT Destination: unreachable.example.com (203.0.113.10) Protocol: TCP +Probe protocol: ICMP Hops: 30 Reached: no Truncated: max-hops @@ -373,7 +434,14 @@ Final hop: avg 24ms / best 22ms / worst 31ms HOPS 1 10.0.0.1 (gateway.local) loss 0% rtt avg 1ms / best 1ms / worst 2ms - 2 198.51.100.5 loss 100% AS64500" + 2 198.51.100.5 loss 100% AS64500 + +TIMING +DNS: 2ms + +ASSERTIONS + ✖ latency property "avg" is less than target "20". Received: 24.1. + ✔ response time is less than target "5000". Received: 1000." `; exports[`formatResultDetail > gRPC check result > renders markdown snapshot > grpc-result-detail-md 1`] = ` @@ -394,7 +462,18 @@ exports[`formatResultDetail > gRPC check result > renders markdown snapshot > gr - **Status:** 14 connection refused - **Health:** NOT_SERVING - **Method:** grpc.health.v1.Health/Check -- **Discovered methods:** grpc.health.v1.Health/Check, grpc.health.v1.Health/Watch" +- **Discovered methods:** grpc.health.v1.Health/Check, grpc.health.v1.Health/Watch + +## Timing +| Phase | Duration | +| --- | --- | +| DNS | 5ms | +| Connect | 40ms | +| **Total** | **90ms** | + +## Assertions +- ✖ status code equals target "0". Received: 14. +- ✔ health check status equals target "NOT_SERVING". Received: NOT_SERVING." `; exports[`formatResultDetail > gRPC check result > renders terminal snapshot > grpc-result-detail-terminal 1`] = ` @@ -417,5 +496,14 @@ Status: 14 connection refused Health: NOT_SERVING Methods: grpc.health.v1.Health/Check, grpc.health.v1.Health/Watch Metadata: - content-type: application/grpc" + content-type: application/grpc + +TIMING +DNS: 5ms +Connect: 40ms +Total: 90ms + +ASSERTIONS + ✖ status code equals target "0". Received: 14. + ✔ health check status equals target "NOT_SERVING". Received: NOT_SERVING." `; diff --git a/packages/cli/src/formatters/__tests__/assertion-line.spec.ts b/packages/cli/src/formatters/__tests__/assertion-line.spec.ts new file mode 100644 index 000000000..8e89dbbe9 --- /dev/null +++ b/packages/cli/src/formatters/__tests__/assertion-line.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' + +import { truncate } from '../assertion-line.js' + +describe('truncate', () => { + it('does not flag a value as truncated when it fits the char cap', () => { + const { truncated, result } = truncate('hello', { chars: 300, ending: '...truncated...' }) + expect(truncated).toBe(false) + expect(result).toBe('hello') + }) + + it('truncates by code point and appends the ending when the cap is exceeded', () => { + const { truncated, result } = truncate('abcdef', { chars: 3, ending: '…' }) + expect(truncated).toBe(true) + expect(result).toBe('abc…') + }) + + it('measures the cap in code points, not UTF-16 units', () => { + // 200 emoji: 200 code points but 400 UTF-16 units. With a 300-char cap the + // value fits by code point, so it must be left whole with no truncation + // suffix — measuring `.length` (400) would wrongly flag it as truncated. + const emoji = '😀'.repeat(200) + const { truncated, result } = truncate(emoji, { chars: 300, ending: '...truncated...' }) + expect(truncated).toBe(false) + expect(result).toBe(emoji) + expect(result).not.toContain('truncated') + }) + + it('never splits a surrogate pair when cutting astral characters', () => { + const { result } = truncate('😀'.repeat(5), { chars: 2, ending: '' }) + expect(result).toBe('😀😀') + expect([...result]).toHaveLength(2) + }) + + it('caps stringified objects, which have no meaningful length', () => { + const { truncated, result } = truncate({ a: 'x'.repeat(500) }, { chars: 100, ending: '…' }) + expect(truncated).toBe(true) + expect([...result]).toHaveLength(101) // 100 code points + the single-char ending + }) +}) diff --git a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts index 006afc027..cfd28ff22 100644 --- a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts +++ b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { stripAnsi } from '../render.js' +import { assertionSymbols } from '../assertion-line.js' + +const PASS = assertionSymbols.success +const FAIL = assertionSymbols.error import { formatResultDetail, groupAttemptsBySequence, @@ -18,8 +22,11 @@ import { agenticCheckResultWithFailures, agenticCheckResultMinimal, tracerouteCheckResult, + tracerouteCheckResultDetail, grpcCheckResult, + grpcCheckResultDetail, sslCheckResult, + sslCheckResultDetail, } from './__fixtures__/fixtures.js' // Pin time for formatDate used in result detail @@ -381,6 +388,37 @@ describe('formatResultDetail', () => { expect(result).toContain('**Hops:** 30') expect(result).toContain('**Truncated:** max-hops') }) + + it('renders the assertions section (terminal)', () => { + const result = stripAnsi(formatResultDetail(tracerouteCheckResult, 'terminal')) + expect(result).toContain('ASSERTIONS') + expect(result).toContain(`${FAIL} latency property "avg" is less than target "20". Received: 24.1.`) + expect(result).toContain(`${PASS} response time is less than target "5000". Received: 1000.`) + }) + + it('renders the assertions section (markdown)', () => { + const result = formatResultDetail(tracerouteCheckResult, 'md') + expect(result).toContain('## Assertions') + expect(result).toContain(`- ${FAIL} latency property "avg" is less than target "20". Received: 24.1.`) + expect(result).toContain(`- ${PASS} response time is less than target "5000". Received: 1000.`) + // Markdown must stay ANSI-free. + expect(result).toBe(stripAnsi(result)) + }) + + it('renders the probe protocol and DNS timing (terminal)', () => { + const result = stripAnsi(formatResultDetail(tracerouteCheckResult, 'terminal')) + expect(result).toContain('Probe protocol:') + expect(result).toContain('ICMP') + expect(result).toContain('TIMING') + expect(result).toContain('DNS:') + }) + + it('renders the probe protocol and DNS timing (markdown)', () => { + const result = formatResultDetail(tracerouteCheckResult, 'md') + expect(result).toContain('**Probe protocol:** ICMP') + expect(result).toContain('## Timing') + expect(result).toContain('- **DNS:**') + }) }) describe('gRPC check result', () => { @@ -413,6 +451,49 @@ describe('formatResultDetail', () => { expect(result).toContain('**Status:** 14 connection refused') expect(result).toContain('**Health:** NOT_SERVING') }) + + it('renders the assertions section with humanized sources (terminal)', () => { + const result = stripAnsi(formatResultDetail(grpcCheckResult, 'terminal')) + expect(result).toContain('ASSERTIONS') + expect(result).toContain(`${FAIL} status code equals target "0". Received: 14.`) + expect(result).toContain(`${PASS} health check status equals target "NOT_SERVING". Received: NOT_SERVING.`) + expect(result).not.toContain('GRPC_STATUS_CODE') + }) + + it('renders the assertions section (markdown)', () => { + const result = formatResultDetail(grpcCheckResult, 'md') + expect(result).toContain('## Assertions') + expect(result).toContain(`- ${FAIL} status code equals target "0". Received: 14.`) + expect(result).toContain(`- ${PASS} health check status equals target "NOT_SERVING". Received: NOT_SERVING.`) + expect(result).toBe(stripAnsi(result)) + }) + + it('renders the gRPC timing breakdown (terminal + markdown)', () => { + const term = stripAnsi(formatResultDetail(grpcCheckResult, 'terminal')) + expect(term).toContain('TIMING') + expect(term).toContain('DNS:') + expect(term).toContain('Connect:') + expect(term).toContain('Total:') + const md = formatResultDetail(grpcCheckResult, 'md') + expect(md).toContain('## Timing') + expect(md).toContain('| Connect |') + expect(md).toContain('| **Total** |') + }) + + it('renders Received for falsy actual values (0 / false)', () => { + const base = grpcCheckResult.grpcCheckResult! + const detail = { + ...base, + assertions: [ + { order: 0, source: 'GRPC_STATUS_CODE', property: '', comparison: 'GREATER_THAN', target: '5', regex: null, error: 'Expected 0 to be above 5', actual: 0 }, + { order: 1, source: 'GRPC_HEALTHCHECK_STATUS', property: '', comparison: 'EQUALS', target: 'true', regex: null, error: 'Expected false to equal true', actual: false }, + ], + } + const res = { ...grpcCheckResult, grpcCheckResult: detail } + const result = stripAnsi(formatResultDetail(res, 'terminal')) + expect(result).toContain('Received: 0.') + expect(result).toContain('Received: false.') + }) }) describe('SSL check result', () => { @@ -447,5 +528,193 @@ describe('formatResultDetail', () => { expect(result).toContain('**Expires in:** expired 5 day(s) ago') expect(result).toContain('**Failure:** expired') }) + + it('renders the assertions section with the failure reason (terminal)', () => { + const result = stripAnsi(formatResultDetail(sslCheckResult, 'terminal')) + expect(result).toContain('ASSERTIONS') + expect(result).toContain(`${FAIL} CERT_EXPIRES_IN_DAYS is greater than target "99999". Received: 52.`) + expect(result).toContain(`${PASS} CERT_NOT_EXPIRED equals target "true". Received: true.`) + }) + + it('renders the assertions section (markdown)', () => { + const result = formatResultDetail(sslCheckResult, 'md') + expect(result).toContain('## Assertions') + expect(result).toContain(`- ${FAIL} CERT_EXPIRES_IN_DAYS is greater than target "99999". Received: 52.`) + expect(result).toContain(`- ${PASS} CERT_NOT_EXPIRED equals target "true". Received: true.`) + expect(result).toBe(stripAnsi(result)) + }) + + it('renders the certificate section (terminal)', () => { + const result = stripAnsi(formatResultDetail(sslCheckResult, 'terminal')) + expect(result).toContain('CERTIFICATE') + expect(result).toContain('Subject CN:') + expect(result).toContain('expired.example.com') + expect(result).toContain('Issuer CN:') + expect(result).toContain('Example Issuing CA') + expect(result).toContain('Valid:') + expect(result).toContain('2026-01-01T00:00:00Z → 2026-07-01T00:00:00Z') + expect(result).toContain('Key:') + expect(result).toContain('ECDSA 256-bit') + expect(result).toContain('Signature:') + expect(result).toContain('ECDSA-SHA256') + expect(result).toContain('SHA-256:') + expect(result).toContain('beab14cf39678fda0ef1606eedb818c2298ba2cc7a00886e7dc2d2410f24cd35') + expect(result).toContain('SANs:') + expect(result).toContain('www.expired.example.com') + expect(result).toContain('OCSP stapled:') + }) + + it('renders the certificate section (markdown)', () => { + const result = formatResultDetail(sslCheckResult, 'md') + expect(result).toContain('## Certificate') + expect(result).toContain('- **Subject CN:** expired.example.com') + expect(result).toContain('- **Issuer CN:** Example Issuing CA') + expect(result).toContain('- **Valid:** 2026-01-01T00:00:00Z → 2026-07-01T00:00:00Z') + expect(result).toContain('- **Key:** ECDSA 256-bit') + expect(result).toContain('- **SHA-256:** `beab14cf39678fda0ef1606eedb818c2298ba2cc7a00886e7dc2d2410f24cd35`') + expect(result).toContain('- **OCSP stapled:** no') + expect(result).toBe(stripAnsi(result)) + }) + + it('renders the per-rule security baseline (terminal)', () => { + const result = stripAnsi(formatResultDetail(sslCheckResult, 'terminal')) + expect(result).toContain('SECURITY BASELINE') + expect(result).toContain(`${PASS} min TLS version (fail)`) + expect(result).toContain(`${FAIL} weak cipher suite (fail)`) + expect(result).toContain(`${PASS} recommended TLS version (ignore)`) + // The one-line summary is still present in the RESULT block. + expect(result).toContain('Baseline:') + }) + + it('renders the per-rule security baseline (markdown)', () => { + const result = formatResultDetail(sslCheckResult, 'md') + expect(result).toContain('## Security Baseline') + expect(result).toContain(`- ${PASS} min TLS version (fail)`) + expect(result).toContain(`- ${FAIL} weak cipher suite (fail)`) + expect(result).toContain(`- ${PASS} SCT present (ignore)`) + expect(result).toBe(stripAnsi(result)) + }) + + it('caps SANs with a +N more suffix', () => { + const base = sslCheckResult.sslCheckResult! + const detail = { + ...base, + response: { + ...base.response, + certificate: { + ...(base.response!.certificate as Record), + sans: Array.from({ length: 13 }, (_, i) => `alt${i}.example.com`), + }, + }, + } + const res = { ...sslCheckResult, sslCheckResult: detail } + const result = stripAnsi(formatResultDetail(res, 'terminal')) + expect(result).toContain('alt0.example.com') + expect(result).toContain('alt9.example.com') + expect(result).toContain('+3 more') + expect(result).not.toContain('alt10.example.com') + }) + + it('renders self-signed and CA flags when set', () => { + const base = sslCheckResult.sslCheckResult! + const detail = { + ...base, + response: { + ...base.response, + certificate: { + ...(base.response!.certificate as Record), + selfSigned: true, + isCA: true, + }, + }, + } + const res = { ...sslCheckResult, sslCheckResult: detail } + const result = stripAnsi(formatResultDetail(res, 'terminal')) + expect(result).toContain('Self-signed:') + expect(result).toContain('CA:') + }) + + it('renders a scalar baseline rule as key: value', () => { + const base = sslCheckResult.sslCheckResult! + const detail = { + ...base, + response: { + ...base.response, + securityBaseline: { + ...(base.response!.securityBaseline as Record), + minTLSVersion: 'TLS1.2', + }, + }, + } + const res = { ...sslCheckResult, sslCheckResult: detail } + const term = stripAnsi(formatResultDetail(res, 'terminal')) + expect(term).toContain('min TLS version: TLS1.2') + const md = formatResultDetail(res, 'md') + expect(md).toContain('min TLS version: TLS1.2') + expect(md).toBe(stripAnsi(md)) + }) + }) + + describe('assertions on a request error', () => { + const requestError = 'dial tcp 203.0.113.10:443: connect: connection refused' + + // An assertion the request never got far enough to evaluate: the backend returns + // it with no `error`, which renders as a passing assertion unless suppressed. + const unevaluated = [ + { + order: 0, + source: 'RESPONSE_TIME', + property: 'avg', + comparison: 'LESS_THAN', + target: '5000', + regex: null, + error: null, + actual: null, + }, + ] + + const cases: Array<[string, CheckResult]> = [ + ['traceroute', { + ...tracerouteCheckResult, + tracerouteCheckResult: { ...tracerouteCheckResultDetail, requestError, assertions: unevaluated }, + }], + ['gRPC', { + ...grpcCheckResult, + grpcCheckResult: { ...grpcCheckResultDetail, requestError, assertions: unevaluated }, + }], + ['SSL', { + ...sslCheckResult, + sslCheckResult: { ...sslCheckResultDetail, requestError, assertions: unevaluated }, + }], + ] + + // The rendered form of `unevaluated`. SSL's SECURITY BASELINE block also uses the + // success symbol, so match the assertion line itself rather than the symbol. + const assertionLine = 'target "5000"' + + it.each(cases)('omits the assertions section for %s (terminal)', (_name, result) => { + const output = stripAnsi(formatResultDetail(result, 'terminal')) + expect(output).toContain('ERROR') + expect(output).toContain(requestError) + expect(output).not.toContain('ASSERTIONS') + expect(output).not.toContain(assertionLine) + }) + + it.each(cases)('omits the assertions section for %s (markdown)', (_name, result) => { + const output = formatResultDetail(result, 'md') + expect(output).toContain('## Error') + expect(output).not.toContain('## Assertions') + expect(output).not.toContain(assertionLine) + }) + + it.each(cases)('still renders assertions for %s without a request error', (_name, result) => { + const key = Object.keys(result).find(k => k.endsWith('CheckResult')) as keyof CheckResult + const detail = result[key] as Record + const cleared = { ...result, [key]: { ...detail, requestError: null } } as CheckResult + + const rendered = stripAnsi(formatResultDetail(cleared, 'terminal')) + expect(rendered).toContain('ASSERTIONS') + expect(rendered).toContain(`${PASS} response time property "avg" is less than ${assertionLine}.`) + }) }) }) diff --git a/packages/cli/src/formatters/assertion-line.ts b/packages/cli/src/formatters/assertion-line.ts new file mode 100644 index 000000000..8a9269434 --- /dev/null +++ b/packages/cli/src/formatters/assertion-line.ts @@ -0,0 +1,155 @@ +import chalk from 'chalk' +import indentString from 'indent-string' + +// Shared per-assertion line renderer. +// +// This is the single source of truth for how a check-result assertion is +// rendered as a human-readable line. Both the `checkly test` reporter +// (`reporters/util.ts`) and the `checkly checks get --result` detail renderer +// (`formatters/check-result-detail.ts`) call `formatAssertionLine` so their +// output is guaranteed identical and cannot drift. + +// Humanized labels for the `source` of an assertion. Falls back to the raw +// source string when unmapped. +const assertionSources: Record = { + STATUS_CODE: 'status code', + JSON_BODY: 'JSON body', + HEADERS: 'headers', + TEXT_BODY: 'text body', + RESPONSE_TIME: 'response time', + RESPONSE_DATA: 'response data', + TEXT_ANSWER: 'answer (text)', + JSON_ANSWER: 'answer (JSON)', + RESPONSE_CODE: 'response code', + LATENCY: 'latency', + JSON_RESPONSE: 'response data (JSON)', + GRPC_STATUS_CODE: 'status code', + GRPC_HEALTHCHECK_STATUS: 'health check status', + GRPC_RESPONSE: 'response message', + GRPC_METADATA: 'metadata', +} + +// Humanized phrases for the `comparison` of an assertion. Falls back to the raw +// comparison string when unmapped. +const assertionComparisons: Record = { + EQUALS: 'equals', + NOT_EQUALS: 'doesn\'t equal', + HAS_KEY: 'has key', + NOT_HAS_KEY: 'doesn\'t have key', + HAS_VALUE: 'has value', + NOT_HAS_VALUE: 'doesn\'t have value', + IS_EMPTY: 'is empty', + NOT_EMPTY: 'is not empty', + GREATER_THAN: 'is greater than', + LESS_THAN: 'is less than', + CONTAINS: 'contains', + NOT_CONTAINS: 'doesn\'t contain', + IS_NULL: 'is null', + NOT_NULL: 'is not null', + MATCHES: 'matches', +} + +export type TruncateOptions = { + chars?: number + lines?: number + ending?: string +} + +function toString (val: any): string { + if (typeof val === 'object') { + return JSON.stringify(val, null, 2) + } else { + return val.toString() + } +} + +export function truncate (val: any, opts: TruncateOptions) { + let truncated = false + let result = toString(val) + // Stringify first so objects/numbers/booleans (which have no meaningful + // `.length`) are capped too, then measure and cut in the same unit by spreading + // to code points. Checking the UTF-16 `.length` would flag a value made of + // astral characters (emoji) as truncated while the code-point cut removed + // nothing; cutting by code point also never splits a surrogate pair into an + // invalid half-character. + if (opts.chars) { + const codePoints = [...result] + if (codePoints.length > opts.chars) { + truncated = true + result = codePoints.slice(0, opts.chars).join('') + } + } + const lines = result.split('\n') + if (opts.lines && lines.length > opts.lines) { + truncated = true + result = lines.slice(0, opts.lines).join('\n') + } + return { + truncated, + result: truncated && opts.ending ? result + opts.ending : result, + lines: opts.lines ? Math.min(opts.lines, lines.length) : lines.length, + } +} + +// The common assertion shape shared by every check type's result body. +export type AssertionLike = { + source?: string + property?: string | null + comparison?: string + target?: unknown + regex?: string | null + error?: string | null + actual?: unknown +} + +export type FormatAssertionLineOptions = { + truncate?: TruncateOptions +} + +export const assertionSymbols = { + success: '✔', + error: '✖', +} as const + +// Renders a single assertion as a colored, human-readable line (or multi-line +// block when the received value spans multiple lines). Green with a success +// symbol when the assertion passed (`error` absent/empty), red with an error +// symbol when it failed. +export function formatAssertionLine ( + assertion: AssertionLike, + options?: FormatAssertionLineOptions, +): string { + const { source, property, comparison, target, regex, error, actual } = assertion + const assertionFailed = !!error + const humanSource = assertionSources[source as string] || source + const humanComparison = assertionComparisons[comparison as string] || comparison + let actualString + // Render the received value whenever one is present — including falsy values + // like `0`, `false`, and `''`. Using a plain truthiness check here dropped the + // "Received:" segment for exactly those, hiding the reason a boolean/numeric + // assertion failed (e.g. CERT_NOT_EXPIRED received `false`, status code `0`). + if (actual != null) { + const { result: truncatedActual, lines: truncatedActualLines } = truncate(actual, { + chars: 300, + lines: 5, + ending: chalk.magenta('\n...truncated...'), + ...options?.truncate, + }) + + if (truncatedActualLines <= 1) { + actualString = `Received: ${truncatedActual}.` + } else { + actualString = `Received:\n${indentString(truncatedActual, 4, { indent: ' ' })}` + } + } + const message = [ + assertionFailed ? assertionSymbols.error : assertionSymbols.success, + humanSource, + property ? `property "${property}"` : undefined, + regex ? `regex "${regex}"` : undefined, + humanComparison, + `target "${target}".`, + actualString, + ].filter(Boolean).join(' ') + return assertionFailed ? chalk.red(message) : chalk.green(message) +} diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index c3448ce8a..753c3b492 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -27,7 +27,9 @@ import { renderCommandHints, renderAdaptiveTable, truncateSingleLine, + stripAnsi, } from './render.js' +import { assertionSymbols, type AssertionLike, formatAssertionLine } from './assertion-line.js' // --- Helpers --- @@ -697,6 +699,48 @@ function formatLatencyStats (obj: unknown): string | undefined { return parts.length > 0 ? parts.join(' / ') : undefined } +// The SSL/gRPC/traceroute result bodies each carry an `assertions` array in the +// common assertion shape. Render them via the shared `formatAssertionLine` +// helper so the output matches the `checkly test` reporter exactly. Terminal +// output keeps the color/symbols from the helper; markdown strips ANSI so the +// list items stay plain text. +// +// Both helpers take the whole result rather than just its assertions, so that the +// request-error check below cannot be forgotten at a call site. +type AssertionsSection = { + requestError?: string | null + assertions?: Array> | null +} + +// A request that errored never evaluated its assertions, and the backend reports +// those unevaluated entries without an `error`, which renders as a green success +// mark. Suppress the whole section instead of claiming assertions passed. +function assertionsToRender (result: AssertionsSection): Array> | undefined { + if (result.requestError) return + if (!result.assertions || result.assertions.length === 0) return + return result.assertions +} + +function appendAssertionsTerminal (lines: string[], result: AssertionsSection): void { + const assertions = assertionsToRender(result) + if (!assertions) return + lines.push('') + lines.push(heading('ASSERTIONS', 2, 'terminal')) + for (const assertion of assertions) { + lines.push(` ${formatAssertionLine(assertion as AssertionLike)}`) + } +} + +function appendAssertionsMd (lines: string[], result: AssertionsSection): void { + const assertions = assertionsToRender(result) + if (!assertions) return + lines.push('') + lines.push('## Assertions') + for (const assertion of assertions) { + lines.push(`- ${stripAnsi(formatAssertionLine(assertion as AssertionLike))}`) + } +} + function formatTracerouteResultTerminal (tr: TracerouteCheckResult): string[] { const lines: string[] = [] const resp = tr.response ?? {} @@ -711,6 +755,8 @@ function formatTracerouteResultTerminal (tr: TracerouteCheckResult): string[] { } const protocol = str(resp.protocol, resp.probeProtocol) if (protocol) lines.push(`${label('Protocol:')}${protocol}`) + const probeProtocol = str(resp.probeProtocol) + if (probeProtocol) lines.push(`${label('Probe protocol:')}${probeProtocol}`) const totalHops = num(tr.totalHops, resp.totalHops) if (totalHops != null) lines.push(`${label('Hops:')}${totalHops}`) @@ -736,6 +782,15 @@ function formatTracerouteResultTerminal (tr: TracerouteCheckResult): string[] { } } + const trDns = num(asObject(tr.timingPhases)?.dns) + if (trDns != null) { + lines.push('') + lines.push(heading('TIMING', 2, 'terminal')) + lines.push(`${label('DNS:')}${formatMs(trDns)}`) + } + + appendAssertionsTerminal(lines, tr) + if (tr.requestError) { lines.push('') lines.push(heading('ERROR', 2, 'terminal')) @@ -770,6 +825,8 @@ function formatTracerouteResultMd (tr: TracerouteCheckResult): string[] { const host = str(resp.hostname) const ip = str(resp.resolvedIp) if (host || ip) lines.push(`- **Destination:** ${[host, ip ? `(${ip})` : null].filter(Boolean).join(' ')}`) + const probeProtocol = str(resp.probeProtocol) + if (probeProtocol) lines.push(`- **Probe protocol:** ${probeProtocol}`) const totalHops = num(tr.totalHops, resp.totalHops) if (totalHops != null) lines.push(`- **Hops:** ${totalHops}`) const reached = boolFlag(tr.destinationReached, resp.destinationReached) @@ -778,7 +835,18 @@ function formatTracerouteResultMd (tr: TracerouteCheckResult): string[] { if (truncation) lines.push(`- **Truncated:** ${truncation}`) const finalHop = formatLatencyStats(tr.finalHopLatency ?? resp.finalHopLatency) if (finalHop) lines.push(`- **Final hop:** ${finalHop}`) - if (tr.requestError) lines.push(`- **Error:** ${tr.requestError}`) + const trDns = num(asObject(tr.timingPhases)?.dns) + if (trDns != null) { + lines.push('') + lines.push('## Timing') + lines.push(`- **DNS:** ${formatMs(trDns)}`) + } + appendAssertionsMd(lines, tr) + if (tr.requestError) { + lines.push('') + lines.push('## Error') + lines.push(`- ${tr.requestError}`) + } return lines } @@ -837,6 +905,22 @@ function formatGrpcResultTerminal (grpc: GrpcCheckResult): string[] { } } + // Timing breakdown. gRPC exposes dns/connect/total (not the HTTP phases the + // API TIMING bar renders), so surface those directly for parity with the API + // result's timing detail instead of only the top-level total response time. + const timing = (grpc.timingPhases ?? resp.timingPhases) as + { dns?: number, connect?: number, total?: number } | undefined + const tDns = num(timing?.dns), tConnect = num(timing?.connect), tTotal = num(timing?.total) + if (tDns != null || tConnect != null || tTotal != null) { + lines.push('') + lines.push(heading('TIMING', 2, 'terminal')) + if (tDns != null) lines.push(`${label('DNS:')}${formatMs(tDns)}`) + if (tConnect != null) lines.push(`${label('Connect:')}${formatMs(tConnect)}`) + if (tTotal != null) lines.push(`${label('Total:')}${formatMs(tTotal)}`) + } + + appendAssertionsTerminal(lines, grpc) + if (grpc.requestError) { lines.push('') lines.push(heading('ERROR', 2, 'terminal')) @@ -869,13 +953,199 @@ function formatGrpcResultMd (grpc: GrpcCheckResult): string[] { if (responseMessage) lines.push(`- **Response:** \`${truncateSingleLine(responseMessage, 200)}\``) const methods = Array.isArray(resp.discoveredMethods) ? resp.discoveredMethods : [] if (methods.length > 0) lines.push(`- **Discovered methods:** ${methods.join(', ')}`) - if (grpc.requestError) lines.push(`- **Error:** ${grpc.requestError}`) + const timing = (grpc.timingPhases ?? resp.timingPhases) as + { dns?: number, connect?: number, total?: number } | undefined + const tDns = num(timing?.dns), tConnect = num(timing?.connect), tTotal = num(timing?.total) + if (tDns != null || tConnect != null || tTotal != null) { + lines.push('') + lines.push('## Timing') + lines.push('| Phase | Duration |') + lines.push('| --- | --- |') + if (tDns != null) lines.push(`| DNS | ${formatMs(tDns)} |`) + if (tConnect != null) lines.push(`| Connect | ${formatMs(tConnect)} |`) + if (tTotal != null) lines.push(`| **Total** | **${formatMs(tTotal)}** |`) + } + appendAssertionsMd(lines, grpc) + if (grpc.requestError) { + lines.push('') + lines.push('## Error') + lines.push(`- ${grpc.requestError}`) + } return lines } // --- SSL check result --- +// SSL security-baseline rules, in display order, mapped to humanized labels. +// Each rule in `response.securityBaseline` is `{ violated, severity }`: a +// non-violated rule renders as a pass, a violated one as a fail, with the +// configured enforcement severity (fail / warn / ignore) shown alongside. +const SSL_BASELINE_RULES: Array<[string, string]> = [ + ['minTLSVersion', 'min TLS version'], + ['minKeySizeBits', 'min key size'], + ['weakSignatureAlgorithm', 'weak signature algorithm'], + ['weakCipherSuite', 'weak cipher suite'], + ['knownBadCA', 'known bad CA'], + ['recommendedTLSVersion', 'recommended TLS version'], + ['recommendedKeySizeBits', 'recommended key size'], + ['ocspMustStapleRespected', 'OCSP must-staple respected'], + ['sctPresent', 'SCT present'], +] + +// Join SANs with a cap so a wildcard cert with dozens of names stays readable. +function formatSans (sans: unknown): string | undefined { + if (!Array.isArray(sans)) return undefined + const list = sans.filter((s): s is string => typeof s === 'string' && s.length > 0) + if (list.length === 0) return undefined + const cap = 10 + if (list.length <= cap) return list.join(', ') + return `${list.slice(0, cap).join(', ')}, +${list.length - cap} more` +} + +function asObject (value: unknown): Record | undefined { + return value && typeof value === 'object' ? value as Record : undefined +} + +function appendSslCertificateTerminal (lines: string[], resp: Record): void { + const cert = asObject(resp.certificate) + const ocspStapled = boolFlag(resp.ocspStapled) + const body: string[] = [] + + if (cert) { + const subjectCN = str(cert.subjectCN) + if (subjectCN) body.push(`${label('Subject CN:')}${subjectCN}`) + const issuerCN = str(cert.issuerCN) + if (issuerCN) body.push(`${label('Issuer CN:')}${issuerCN}`) + const notBefore = str(cert.notBefore) + const notAfter = str(cert.notAfter) + if (notBefore || notAfter) body.push(`${label('Valid:')}${[notBefore, notAfter].filter(Boolean).join(' → ')}`) + const keyAlgorithm = str(cert.keyAlgorithm) + const keySizeBits = num(cert.keySizeBits) + if (keyAlgorithm || keySizeBits != null) { + body.push(`${label('Key:')}${[keyAlgorithm, keySizeBits != null ? `${keySizeBits}-bit` : null].filter(Boolean).join(' ')}`) + } + const signatureAlgorithm = str(cert.signatureAlgorithm) + if (signatureAlgorithm) body.push(`${label('Signature:')}${signatureAlgorithm}`) + const fingerprint = str(cert.fingerprintSha256) + if (fingerprint) body.push(`${label('SHA-256:')}${fingerprint}`) + const sans = formatSans(cert.sans) + if (sans) body.push(`${label('SANs:')}${sans}`) + const serial = str(cert.serialNumber) + if (serial) body.push(`${label('Serial:')}${serial}`) + if (boolFlag(cert.selfSigned) === true) body.push(`${label('Self-signed:')}${chalk.yellow('yes')}`) + if (boolFlag(cert.isCA) === true) body.push(`${label('CA:')}yes`) + } + if (ocspStapled != null) body.push(`${label('OCSP stapled:')}${yesNo(ocspStapled)}`) + + if (body.length === 0) return + lines.push('') + lines.push(heading('CERTIFICATE', 2, 'terminal')) + lines.push(...body) +} + +function appendSslCertificateMd (lines: string[], resp: Record): void { + const cert = asObject(resp.certificate) + const ocspStapled = boolFlag(resp.ocspStapled) + const body: string[] = [] + + if (cert) { + const subjectCN = str(cert.subjectCN) + if (subjectCN) body.push(`- **Subject CN:** ${subjectCN}`) + const issuerCN = str(cert.issuerCN) + if (issuerCN) body.push(`- **Issuer CN:** ${issuerCN}`) + const notBefore = str(cert.notBefore) + const notAfter = str(cert.notAfter) + if (notBefore || notAfter) body.push(`- **Valid:** ${[notBefore, notAfter].filter(Boolean).join(' → ')}`) + const keyAlgorithm = str(cert.keyAlgorithm) + const keySizeBits = num(cert.keySizeBits) + if (keyAlgorithm || keySizeBits != null) { + body.push(`- **Key:** ${[keyAlgorithm, keySizeBits != null ? `${keySizeBits}-bit` : null].filter(Boolean).join(' ')}`) + } + const signatureAlgorithm = str(cert.signatureAlgorithm) + if (signatureAlgorithm) body.push(`- **Signature:** ${signatureAlgorithm}`) + const fingerprint = str(cert.fingerprintSha256) + if (fingerprint) body.push(`- **SHA-256:** \`${fingerprint}\``) + const sans = formatSans(cert.sans) + if (sans) body.push(`- **SANs:** ${sans}`) + const serial = str(cert.serialNumber) + if (serial) body.push(`- **Serial:** ${serial}`) + if (boolFlag(cert.selfSigned) === true) body.push('- **Self-signed:** yes') + if (boolFlag(cert.isCA) === true) body.push('- **CA:** yes') + } + if (ocspStapled != null) body.push(`- **OCSP stapled:** ${ocspStapled ? 'yes' : 'no'}`) + + if (body.length === 0) return + lines.push('') + lines.push('## Certificate') + lines.push(...body) +} + +// Build the per-rule baseline breakdown as [symbol, humanLabel, severity, violated] +// tuples so terminal and markdown can render the same rules with format-specific +// styling. A rule that carries neither a `violated` flag nor a scalar value is +// skipped. +function sslBaselineRules ( + resp: Record, +): Array<{ human: string, violated?: boolean, severity?: string, scalar?: string }> { + const baseline = asObject(resp.securityBaseline) + if (!baseline) return [] + const rules: Array<{ human: string, violated?: boolean, severity?: string, scalar?: string }> = [] + for (const [key, human] of SSL_BASELINE_RULES) { + const rule = baseline[key] + if (rule == null) continue + const obj = asObject(rule) + if (obj) { + const violated = boolFlag(obj.violated) + const severity = str(obj.severity) + if (violated == null && severity == null) continue + rules.push({ human, violated, severity }) + } else if (typeof rule === 'string' || typeof rule === 'number' || typeof rule === 'boolean') { + rules.push({ human, scalar: String(rule) }) + } + } + return rules +} + +function appendSslBaselineTerminal (lines: string[], resp: Record): void { + const rules = sslBaselineRules(resp) + if (rules.length === 0) return + lines.push('') + lines.push(heading('SECURITY BASELINE', 2, 'terminal')) + for (const rule of rules) { + if (rule.violated == null && rule.scalar == null) { + lines.push(` ${chalk.dim('·')} ${rule.human}${rule.severity ? chalk.dim(` (${rule.severity})`) : ''}`) + continue + } + if (rule.scalar != null) { + lines.push(` ${chalk.dim('·')} ${rule.human}: ${rule.scalar}`) + continue + } + const head = `${rule.violated ? assertionSymbols.error : assertionSymbols.success} ${rule.human}` + const coloredHead = rule.violated ? chalk.red(head) : chalk.green(head) + lines.push(` ${coloredHead}${rule.severity ? chalk.dim(` (${rule.severity})`) : ''}`) + } +} + +function appendSslBaselineMd (lines: string[], resp: Record): void { + const rules = sslBaselineRules(resp) + if (rules.length === 0) return + lines.push('') + lines.push('## Security Baseline') + for (const rule of rules) { + if (rule.scalar != null) { + lines.push(`- ${rule.human}: ${rule.scalar}`) + continue + } + if (rule.violated == null) { + lines.push(`- ${rule.human}${rule.severity ? ` (${rule.severity})` : ''}`) + continue + } + const sym = rule.violated ? assertionSymbols.error : assertionSymbols.success + lines.push(`- ${sym} ${rule.human}${rule.severity ? ` (${rule.severity})` : ''}`) + } +} + function formatSslResultTerminal (ssl: SslCheckResult): string[] { const lines: string[] = [] const resp = ssl.response ?? {} @@ -913,6 +1183,11 @@ function formatSslResultTerminal (ssl: SslCheckResult): string[] { const failure = str(ssl.failureCategory) if (failure) lines.push(`${label('Failure:')}${chalk.red(failure)}`) + appendSslCertificateTerminal(lines, resp) + appendSslBaselineTerminal(lines, resp) + + appendAssertionsTerminal(lines, ssl) + if (ssl.requestError) { lines.push('') lines.push(heading('ERROR', 2, 'terminal')) @@ -942,7 +1217,14 @@ function formatSslResultMd (ssl: SslCheckResult): string[] { if (verdict || grade) lines.push(`- **Baseline:** ${[verdict, grade ? `grade ${grade}` : ''].filter(Boolean).join(' ')}`) const failure = str(ssl.failureCategory) if (failure) lines.push(`- **Failure:** ${failure}`) - if (ssl.requestError) lines.push(`- **Error:** ${ssl.requestError}`) + appendSslCertificateMd(lines, resp) + appendSslBaselineMd(lines, resp) + appendAssertionsMd(lines, ssl) + if (ssl.requestError) { + lines.push('') + lines.push('## Error') + lines.push(`- ${ssl.requestError}`) + } return lines } diff --git a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap index 9a6ff4f78..286a4b9b9 100644 --- a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap +++ b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap @@ -253,8 +253,10 @@ Resolved IP: 203.0.113.30 TLS: TLS 1.3 / TLS_AES_256_GCM_SHA384 Expires in: expired 5 day(s) ago Handshake: 48.200ms +Response time 48.200ms exceeded max 40.000ms Chain Trusted: no -Hostname Verified: no" +Hostname Verified: no +Baseline: fail (grade F)" `; exports[`formatCheckResult() > Traceroute Check result > formats a failing Traceroute Check result > traceroute-check-result-format 1`] = ` @@ -275,6 +277,7 @@ exports[`formatCheckResult() > gRPC Check result > formats a failing gRPC Check Target: grpc.example.com:443 Method: grpc.health.v1.Health/Check Mode: HEALTH +Response Time: 90ms Status: 14 connection refused Health: NOT_SERVING Discovered Methods: grpc.health.v1.Health/Check, grpc.health.v1.Health/Watch diff --git a/packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts b/packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts index 4ad4c6742..b447640ca 100644 --- a/packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts +++ b/packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts @@ -77,6 +77,7 @@ export const grpcCheckResult = { healthStatusLabel: 'NOT_SERVING', metadata: [{ key: 'content-type', value: 'application/grpc' }], discoveredMethods: ['grpc.health.v1.Health/Check', 'grpc.health.v1.Health/Watch'], + timingPhases: { dns: 1, connect: 2, total: 90 }, }, }, logs: [], @@ -104,6 +105,8 @@ export const sslCheckResult = { responseTime: 48, checkRunData: { requestError: null, + degradedResponseTime: 20, + maxResponseTime: 40, assertions: [], response: { resolvedIp: '203.0.113.30', @@ -114,6 +117,7 @@ export const sslCheckResult = { chainTrusted: false, daysUntilExpiry: -5, ocspStapled: false, + securityBaseline: { verdict: 'fail', grade: 'F' }, }, }, logs: [], diff --git a/packages/cli/src/reporters/__tests__/util.spec.ts b/packages/cli/src/reporters/__tests__/util.spec.ts index 004ea5a47..2fa4d4596 100644 --- a/packages/cli/src/reporters/__tests__/util.spec.ts +++ b/packages/cli/src/reporters/__tests__/util.spec.ts @@ -163,6 +163,42 @@ describe('formatCheckResult()', () => { expect(output).toContain('Health: NOT_SERVING') expect(output).toContain('grpc.health.v1.Health/Watch') }) + it('renders the response time from timingPhases.total', () => { + const output = stripAnsi(formatCheckResult(grpcCheckResult)) + expect(output).toContain('Response Time: 90ms') + }) + it('humanizes gRPC assertion sources', () => { + const withAssertions = { + ...grpcCheckResult, + checkRunData: { + ...grpcCheckResult.checkRunData, + assertions: [ + { source: 'GRPC_STATUS_CODE', comparison: 'EQUALS', property: '', regex: null, target: '0', error: 'boom', actual: 14 }, + { source: 'GRPC_HEALTHCHECK_STATUS', comparison: 'EQUALS', property: '', regex: null, target: 'SERVING', error: 'boom', actual: 'NOT_SERVING' }, + ], + }, + } + const output = stripAnsi(formatCheckResult(withAssertions)) + expect(output).toContain('status code') + expect(output).toContain('health check status') + expect(output).not.toContain('GRPC_STATUS_CODE') + }) + it('suppresses the Assertions block when a requestError is set', () => { + const withError = { + ...grpcCheckResult, + checkRunData: { + ...grpcCheckResult.checkRunData, + requestError: 'connection refused', + assertions: [ + { source: 'GRPC_STATUS_CODE', comparison: 'EQUALS', property: '', regex: null, target: '0', error: '', actual: 14 }, + ], + }, + } + const output = stripAnsi(formatCheckResult(withError)) + expect(output).toContain('Request Error') + expect(output).not.toContain('Assertions') + expect(output).not.toContain('status code') + }) }) describe('SSL Check result', () => { it('formats a failing SSL Check result', () => { @@ -177,6 +213,36 @@ describe('formatCheckResult()', () => { expect(output).toContain('Chain Trusted: no') expect(output).toContain('Hostname Verified: no') }) + it('explains a response-time failure and renders the baseline verdict', () => { + const output = stripAnsi(formatCheckResult(sslCheckResult)) + // handshakeTimeMs 48.2 > maxResponseTime 40 + expect(output).toContain('Response time 48.200ms exceeded max 40.000ms') + expect(output).toContain('Baseline: fail (grade F)') + }) + it('explains a degraded response-time verdict when only degraded is exceeded', () => { + const degraded = { + ...sslCheckResult, + checkRunData: { + ...sslCheckResult.checkRunData, + degradedResponseTime: 40, + maxResponseTime: 60, + }, + } + const output = stripAnsi(formatCheckResult(degraded)) + expect(output).toContain('Response time 48.200ms exceeded degraded 40.000ms') + }) + it('omits the response-time reason when thresholds are not exceeded', () => { + const ok = { + ...sslCheckResult, + checkRunData: { + ...sslCheckResult.checkRunData, + degradedResponseTime: 100, + maxResponseTime: 200, + }, + } + const output = stripAnsi(formatCheckResult(ok)) + expect(output).not.toContain('exceeded') + }) }) }) diff --git a/packages/cli/src/reporters/util.ts b/packages/cli/src/reporters/util.ts index feeb852a1..b9444a167 100644 --- a/packages/cli/src/reporters/util.ts +++ b/packages/cli/src/reporters/util.ts @@ -6,7 +6,12 @@ import { DateTime } from 'luxon' import logSymbols from 'log-symbols' import { getDefaults } from '../rest/api.js' -import { Assertion } from '../constructs/internal/assertion.js' +import { + type AssertionLike, + type TruncateOptions, + formatAssertionLine, + truncate, +} from '../formatters/assertion-line.js' // eslint-disable-next-line no-restricted-syntax export enum CheckStatus { @@ -95,7 +100,7 @@ export function formatCheckResult (checkResult: any) { formatHttpResponse(checkResult.checkRunData.response), ]) } - if (checkResult.checkRunData?.assertions?.length) { + if (!checkResult.checkRunData?.requestError && checkResult.checkRunData?.assertions?.length) { result.push([ formatSectionTitle('Assertions'), formatAssertions(checkResult.checkRunData.assertions), @@ -146,7 +151,7 @@ export function formatCheckResult (checkResult: any) { formatDnsResponse(checkResult.checkRunData.response), ]) } - if (checkResult.checkRunData?.assertions?.length) { + if (!checkResult.checkRunData?.requestError && checkResult.checkRunData?.assertions?.length) { result.push([ formatSectionTitle('Assertions'), formatAssertions(checkResult.checkRunData.assertions, { @@ -172,7 +177,7 @@ export function formatCheckResult (checkResult: any) { formatConnectionError(checkResult.checkRunData?.response?.error), ]) } - if (checkResult.checkRunData?.assertions?.length) { + if (!checkResult.checkRunData?.requestError && checkResult.checkRunData?.assertions?.length) { result.push([ formatSectionTitle('Assertions'), formatAssertions(checkResult.checkRunData.assertions), @@ -253,7 +258,7 @@ export function formatCheckResult (checkResult: any) { ]) } } - if (checkResult.checkRunData?.assertions?.length) { + if (!checkResult.checkRunData?.requestError && checkResult.checkRunData?.assertions?.length) { result.push([ formatSectionTitle('Assertions'), formatAssertions(checkResult.checkRunData.assertions), @@ -278,7 +283,7 @@ export function formatCheckResult (checkResult: any) { ]) } } - if (checkResult.checkRunData?.assertions?.length) { + if (!checkResult.checkRunData?.requestError && checkResult.checkRunData?.assertions?.length) { result.push([ formatSectionTitle('Assertions'), formatAssertions(checkResult.checkRunData.assertions), @@ -294,7 +299,10 @@ export function formatCheckResult (checkResult: any) { } else if (checkResult.checkRunData?.response) { result.push([ formatSectionTitle('SSL Response'), - formatSslResponse(checkResult.checkRunData.response), + formatSslResponse(checkResult.checkRunData.response, { + degradedResponseTime: checkResult.checkRunData.degradedResponseTime, + maxResponseTime: checkResult.checkRunData.maxResponseTime, + }), ]) if (checkResult.checkRunData?.response?.error) { result.push([ @@ -303,7 +311,7 @@ export function formatCheckResult (checkResult: any) { ]) } } - if (checkResult.checkRunData?.assertions?.length) { + if (!checkResult.checkRunData?.requestError && checkResult.checkRunData?.assertions?.length) { result.push([ formatSectionTitle('Assertions'), formatAssertions(checkResult.checkRunData.assertions), @@ -335,7 +343,7 @@ export function formatCheckResult (checkResult: any) { formatConnectionError(checkResult.checkRunData?.response?.error), ]) } - if (checkResult.checkRunData?.assertions?.length) { + if (!checkResult.checkRunData?.requestError && checkResult.checkRunData?.assertions?.length) { result.push([ formatSectionTitle('Assertions'), formatAssertions(checkResult.checkRunData.assertions), @@ -364,75 +372,15 @@ export function formatCheckResult (checkResult: any) { return result.map(([title, body]) => title + '\n' + body).join('\n\n') } -const assertionSources: any = { - STATUS_CODE: 'status code', - JSON_BODY: 'JSON body', - HEADERS: 'headers', - TEXT_BODY: 'text body', - RESPONSE_TIME: 'response time', - RESPONSE_DATA: 'response data', - TEXT_ANSWER: 'answer (text)', - JSON_ANSWER: 'answer (JSON)', - RESPONSE_CODE: 'response code', - LATENCY: 'latency', - JSON_RESPONSE: 'response data (JSON)', -} - -const assertionComparisons: any = { - EQUALS: 'equals', - NOT_EQUALS: 'doesn\'t equal', - HAS_KEY: 'has key', - NOT_HAS_KEY: 'doesn\'t have key', - HAS_VALUE: 'has value', - NOT_HAS_VALUE: 'doesn\'t have value', - IS_EMPTY: 'is empty', - NOT_EMPTY: 'is not empty', - GREATER_THAN: 'is greater than', - LESS_THAN: 'is less than', - CONTAINS: 'contains', - NOT_CONTAINS: 'doesn\'t contain', - IS_NULL: 'is null', - NOT_NULL: 'is not null', -} - type formatAssertionsOptions = { truncate?: TruncateOptions } function formatAssertions ( - assertions: Array & { error: string, actual: any }>, + assertions: Array, options?: formatAssertionsOptions, ) { - return assertions.map(({ source, property, comparison, target, regex, error, actual }) => { - const assertionFailed = !!error - const humanSource = assertionSources[source] || source - const humanComparison = assertionComparisons[comparison] || comparison - let actualString - if (actual) { - const { result: truncatedActual, lines: truncatedActualLines } = truncate(actual, { - chars: 300, - lines: 5, - ending: chalk.magenta('\n...truncated...'), - ...options?.truncate, - }) - - if (truncatedActualLines <= 1) { - actualString = `Received: ${truncatedActual}.` - } else { - actualString = `Received:\n${indentString(truncatedActual, 4, { indent: ' ' })}` - } - } - const message = [ - assertionFailed ? logSymbols.error : logSymbols.success, - humanSource, - property ? `property "${property}"` : undefined, - regex ? `regex "${regex}"` : undefined, - humanComparison, - `target "${target}".`, - actualString, - ].filter(Boolean).join(' ') - return assertionFailed ? chalk.red(message) : chalk.green(message) - }).join('\n') + return assertions.map(assertion => formatAssertionLine(assertion, options)).join('\n') } function formatHttpRequest (request: any) { @@ -666,7 +614,7 @@ function formatIcmpResponse (response: ICMPResponse) { // Traceroute / gRPC / SSL diagnostics for the `checkly test` runner path. The // runner artifact (`checkRunData.response`) mirrors the public check-results -// `response` sub-object; keys come straight off the go-runner (snake_case for +// `response` sub-object; keys come straight off the backend (snake_case for // traceroute hops/latency), so read defensively. function latencyStats (obj: any): string | undefined { @@ -717,10 +665,14 @@ function formatGrpcResponse (response: any) { const methods = Array.isArray(response.discoveredMethods) ? response.discoveredMethods : [] const metadata = Array.isArray(response.metadata) ? response.metadata : [] const metadataLines = metadata.slice(0, 20).map((m: any) => ` ${m.key ?? m.name ?? '(key)'}: ${m.value ?? ''}`) + // The backend reports gRPC response time as `timingPhases.total` (ms); fall + // back to a flat `responseTime` if a future artifact exposes one. + const responseTime = response.timingPhases?.total ?? response.responseTime return [ target ? `Target: ${target}` : undefined, response.grpcMethod ? `Method: ${response.grpcMethod}` : undefined, response.grpcMode ? `Mode: ${response.grpcMode}` : undefined, + typeof responseTime === 'number' ? `Response Time: ${formatDuration(responseTime)}` : undefined, response.grpcStatusCode != null ? `Status: ${response.grpcStatusCode}${response.grpcStatusMessage ? ` ${response.grpcStatusMessage}` : ''}` : undefined, @@ -732,8 +684,31 @@ function formatGrpcResponse (response: any) { ].filter(Boolean).join('\n') } -function formatSslResponse (response: any) { +type SslResponseTimeLimits = { + degradedResponseTime?: number + maxResponseTime?: number +} + +function formatSslResponse (response: any, limits?: SslResponseTimeLimits) { const days = response.daysUntilExpiry + const handshake = response.handshakeTimeMs + // Explain a response-time fail/degraded verdict. The thresholds live on the + // enclosing checkRunData (not the response artifact), so they are passed in; + // when they are absent we simply skip the reason line. + let responseTimeReason + if (typeof handshake === 'number') { + if (limits?.maxResponseTime != null && handshake > limits.maxResponseTime) { + responseTimeReason = `Response time ${formatLatency(handshake)} exceeded max ${formatLatency(limits.maxResponseTime)}` + } else if (limits?.degradedResponseTime != null && handshake > limits.degradedResponseTime) { + responseTimeReason = `Response time ${formatLatency(handshake)} exceeded degraded ${formatLatency(limits.degradedResponseTime)}` + } + } + // Render the security-baseline verdict/grade when the runner provides it. + const baseline = response.securityBaseline + let baselineLine + if (baseline && (baseline.verdict || baseline.grade)) { + baselineLine = `Baseline: ${[baseline.verdict, baseline.grade ? `(grade ${baseline.grade})` : ''].filter(Boolean).join(' ')}` + } return [ response.resolvedIp ? `Resolved IP: ${response.resolvedIp}` : undefined, response.protocol || response.cipherSuite @@ -742,9 +717,11 @@ function formatSslResponse (response: any) { typeof days === 'number' ? `Expires in: ${days < 0 ? `expired ${-days} day(s) ago` : `${days} day(s)`}` : undefined, - typeof response.handshakeTimeMs === 'number' ? `Handshake: ${formatLatency(response.handshakeTimeMs)}` : undefined, + typeof handshake === 'number' ? `Handshake: ${formatLatency(handshake)}` : undefined, + responseTimeReason, response.chainTrusted != null ? `Chain Trusted: ${response.chainTrusted ? 'yes' : 'no'}` : undefined, response.hostnameVerified != null ? `Hostname Verified: ${response.hostnameVerified ? 'yes' : 'no'}` : undefined, + baselineLine, ].filter(Boolean).join('\n') } @@ -839,39 +816,6 @@ function formatSectionTitle (title: string): string { return `──${chalk.bold(title)}${'─'.repeat(rightPaddingLength)}` } -type TruncateOptions = { - chars?: number - lines?: number - ending?: string -} - -function truncate (val: any, opts: TruncateOptions) { - let truncated = false - let result = toString(val) - if (opts.chars && val.length > opts.chars) { - truncated = true - result = result.substring(0, opts.chars) - } - const lines = result.split('\n') - if (opts.lines && lines.length > opts.lines) { - truncated = true - result = lines.slice(0, opts.lines).join('\n') - } - return { - truncated, - result: truncated && opts.ending ? result + opts.ending : result, - lines: opts.lines ? Math.min(opts.lines, lines.length) : lines.length, - } -} - -function toString (val: any): string { - if (typeof val === 'object') { - return JSON.stringify(val, null, 2) - } else { - return val.toString() - } -} - export function resultToCheckStatus (checkResult: any): CheckStatus { if (checkResult.isCancelled) { return CheckStatus.CANCELLED