diff --git a/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts b/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts index 906e580d7..9cd5b0559 100644 --- a/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts @@ -109,23 +109,36 @@ 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 not error within limits', async () => { setupProject() const check = new GrpcMonitor('test-check', { diff --git a/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts b/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts new file mode 100644 index 000000000..e68335835 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' + +import { valueForSslAssertion } from '../ssl-assertion-codegen.js' +import { SslAssertion } from '../ssl-assertion.js' +import { GeneratedFile, Output } from '../../sourcegen/index.js' + +function render (assertion: SslAssertion): string { + const output = new Output() + const file = new GeneratedFile('foo.ts') + const result = valueForSslAssertion(file, assertion) + result.render(output) + return output.finalize() +} + +describe('SSL Assertion Codegen', () => { + it('generates the new SSL assertion sources', () => { + const cases: { input: SslAssertion, expected: string }[] = [ + { + input: { source: 'HANDSHAKE_TIME_MS', property: '', comparison: 'LESS_THAN', target: '500', regex: null }, + expected: 'SslAssertionBuilder.handshakeTimeMs().lessThan(500)\n', + }, + { + input: { source: 'OCSP_STAPLED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, + expected: "SslAssertionBuilder.ocspStapled().equals('true')\n", + }, + { + input: { source: 'SAN_CONTAINS', property: '', comparison: 'EQUALS', target: 'example.com', regex: null }, + expected: "SslAssertionBuilder.sanContains().equals('example.com')\n", + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) + + it('generates the new comparison operators', () => { + const cases: { input: SslAssertion, expected: string }[] = [ + { + input: { source: 'KEY_SIZE_BITS', property: '', comparison: 'GREATER_THAN_OR_EQUAL', target: '2048', regex: null }, + expected: 'SslAssertionBuilder.keySizeBits().greaterThanOrEqual(2048)\n', + }, + { + input: { source: 'TLS_VERSION', property: '', comparison: 'GREATER_THAN_OR_EQUAL', target: 'TLS1.2', regex: null }, + expected: "SslAssertionBuilder.tlsVersion().greaterThanOrEqual('TLS1.2')\n", + }, + { + input: { source: 'CIPHER_SUITE', property: '', comparison: 'MATCHES', target: '^TLS_AES_.*', regex: null }, + expected: "SslAssertionBuilder.cipherSuite().matches('^TLS_AES_.*')\n", + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) +}) diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts index d574d438d..c71c0d4f7 100644 --- a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -116,6 +116,56 @@ describe('SslMonitor', () => { expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) }) + describe('assertions', () => { + it('synthesizes the new SSL assertion sources with backend-compatible operators', () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { + hostname: 'example.com', + sslConfig: {}, + assertions: [ + SslAssertionBuilder.keySizeBits().greaterThanOrEqual(2048), + SslAssertionBuilder.tlsVersion().greaterThanOrEqual('TLS1.2'), + SslAssertionBuilder.cipherSuite().matches('^TLS_AES_.*'), + SslAssertionBuilder.issuerCn().matches('(?i)let\'s encrypt.*'), + SslAssertionBuilder.handshakeTimeMs().lessThan(500), + SslAssertionBuilder.ocspStapled().equals(true), + SslAssertionBuilder.sanContains().equals('example.com'), + ], + }, + }) + + const payload = check.synthesize() as any + expect(payload.request.assertions).toEqual([ + expect.objectContaining({ source: 'KEY_SIZE_BITS', comparison: 'GREATER_THAN_OR_EQUAL', target: '2048' }), + expect.objectContaining({ source: 'TLS_VERSION', comparison: 'GREATER_THAN_OR_EQUAL', target: 'TLS1.2' }), + expect.objectContaining({ source: 'CIPHER_SUITE', comparison: 'MATCHES', target: '^TLS_AES_.*' }), + expect.objectContaining({ source: 'ISSUER_CN', comparison: 'MATCHES' }), + expect.objectContaining({ source: 'HANDSHAKE_TIME_MS', comparison: 'LESS_THAN', target: '500' }), + expect.objectContaining({ source: 'OCSP_STAPLED', comparison: 'EQUALS', target: 'true' }), + expect.objectContaining({ source: 'SAN_CONTAINS', comparison: 'EQUALS', target: 'example.com' }), + ]) + }) + + it('accepts a "degrade" baseline severity', () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { + hostname: 'example.com', + sslConfig: { + securityBaseline: { + minTLSVersion: { value: 'TLS1.2', severity: 'degrade' }, + }, + }, + }, + }) + const payload = check.synthesize() as any + expect(payload.request.sslConfig.securityBaseline.minTLSVersion.severity).toBe('degrade') + }) + }) + describe('validation', () => { it('should error when clientCertificateMode is explicit but no certificate id is set', async () => { setupProject() @@ -176,6 +226,43 @@ describe('SslMonitor', () => { ])) }) + it('should error when degradedResponseTime exceeds the default maxResponseTime and max is omitted', async () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + // 20000 is within the 30000 degraded bound but above the 10000 default + // maxResponseTime the backend applies when max is omitted. + degradedResponseTime: 20000, + request: { + hostname: 'example.com', + sslConfig: {}, + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be less than or equal to the default "maxResponseTime" of 10000'), + }), + ])) + }) + + it('should not error when degradedResponseTime is within the default maxResponseTime and max is omitted', async () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + degradedResponseTime: 5000, + request: { + hostname: 'example.com', + sslConfig: {}, + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + it('should not error for a valid config', async () => { setupProject() const check = new SslMonitor('test-check', { name: 'Test Check', request }) diff --git a/packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts b/packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts new file mode 100644 index 000000000..04a54e5b3 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/traceroute-assertion-codegen.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' + +import { valueForTracerouteAssertion } from '../traceroute-assertion-codegen.js' +import { TracerouteAssertion } from '../traceroute-assertion.js' +import { GeneratedFile, Output } from '../../sourcegen/index.js' + +function render (assertion: TracerouteAssertion): string { + const output = new Output() + const file = new GeneratedFile('foo.ts') + const result = valueForTracerouteAssertion(file, assertion) + result.render(output) + return output.finalize() +} + +describe('Traceroute Assertion Codegen', () => { + it('preserves the response-time property as a selector', () => { + const cases: { input: TracerouteAssertion, expected: string }[] = [ + { + input: { source: 'RESPONSE_TIME', property: 'max', comparison: 'LESS_THAN', target: '1000', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().max().lessThan(1000)\n', + }, + { + input: { source: 'RESPONSE_TIME', property: 'min', comparison: 'GREATER_THAN', target: '10', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().min().greaterThan(10)\n', + }, + { + input: { source: 'RESPONSE_TIME', property: 'stdDev', comparison: 'LESS_THAN', target: '50', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().stdDev().lessThan(50)\n', + }, + { + input: { source: 'RESPONSE_TIME', property: 'avg', comparison: 'LESS_THAN', target: '1000', regex: null }, + expected: 'TracerouteAssertionBuilder.responseTime().avg().lessThan(1000)\n', + }, + ] + for (const test of cases) { + expect(render(test.input)).toEqual(test.expected) + } + }) + + it('emits a bare responseTime() when no property is set', () => { + const input: TracerouteAssertion = + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null } + expect(render(input)).toEqual('TracerouteAssertionBuilder.responseTime().lessThan(1000)\n') + }) + + it('does not emit a property selector for HOP_COUNT / PACKET_LOSS', () => { + const hop: TracerouteAssertion = + { source: 'HOP_COUNT', property: '', comparison: 'LESS_THAN', target: '20', regex: null } + expect(render(hop)).toEqual('TracerouteAssertionBuilder.hopCount().lessThan(20)\n') + + const loss: TracerouteAssertion = + { source: 'PACKET_LOSS', property: '', comparison: 'LESS_THAN', target: '10', regex: null } + expect(render(loss)).toEqual('TracerouteAssertionBuilder.packetLoss().lessThan(10)\n') + }) +}) diff --git a/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts b/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts index d7ad93c2e..6d14be806 100644 --- a/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts @@ -84,7 +84,60 @@ describe('TracerouteMonitor', () => { expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) }) + describe('responseTime() assertions', () => { + it('should default the property to "avg" so responseTime().lessThan() is usable', () => { + const assertion = TracerouteAssertionBuilder.responseTime().lessThan(1000) + expect(assertion).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'avg', + comparison: 'LESS_THAN', + target: '1000', + }) + }) + + it('should support selecting a specific response-time property', () => { + expect(TracerouteAssertionBuilder.responseTime().max().lessThan(2000)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'max', + comparison: 'LESS_THAN', + target: '2000', + }) + expect(TracerouteAssertionBuilder.responseTime().min().greaterThan(1)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'min', + comparison: 'GREATER_THAN', + }) + expect(TracerouteAssertionBuilder.responseTime().stdDev().lessThan(50)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'stdDev', + }) + expect(TracerouteAssertionBuilder.responseTime().avg().equals(100)).toMatchObject({ + source: 'RESPONSE_TIME', + property: 'avg', + comparison: 'EQUALS', + }) + }) + }) + describe('validation', () => { + it('should error when degradedResponseTime exceeds maxResponseTime', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 20000, + maxResponseTime: 10000, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be less than or equal to "maxResponseTime"'), + }), + ])) + }) + it('should error if maxResponseTime is above 30000', async () => { setupProject() const check = new TracerouteMonitor('test-check', { diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts index e8be032e7..585e27cdc 100644 --- a/packages/cli/src/constructs/grpc-monitor.ts +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -16,7 +16,7 @@ export interface GrpcMonitorProps extends MonitorProps { * * @defaultValue 4000 * @minimum 0 - * @maximum 30000 + * @maximum 180000 * @example * ```typescript * degradedResponseTime: 3000 // Alert when the gRPC call takes longer than 3 seconds @@ -30,7 +30,7 @@ export interface GrpcMonitorProps extends MonitorProps { * * @defaultValue 5000 * @minimum 0 - * @maximum 30000 + * @maximum 180000 * @example * ```typescript * maxResponseTime: 10000 // Fail if the gRPC call takes longer than 10 seconds @@ -76,8 +76,12 @@ 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 grpcResponseTimeLimitFields in response-time-limit-schema.ts. + degradedResponseTime: 180_000, + maxResponseTime: 180_000, + // Backend default applied when maxResponseTime is omitted. + defaultMaxResponseTime: 20_000, }) } diff --git a/packages/cli/src/constructs/internal/assertion-codegen.ts b/packages/cli/src/constructs/internal/assertion-codegen.ts index cfd152ccf..df4626a57 100644 --- a/packages/cli/src/constructs/internal/assertion-codegen.ts +++ b/packages/cli/src/constructs/internal/assertion-codegen.ts @@ -3,6 +3,10 @@ import { Assertion } from './assertion.js' export interface ValueForNumericAssertionOptions { hasProperty?: boolean + // When set, emit the assertion property as a chained selector method call + // (e.g. `responseTime().max()`) instead of a method argument, but only when + // the property is one of the listed selector names. + propertySelectors?: readonly string[] } export function valueForNumericAssertion ( @@ -19,6 +23,11 @@ export function valueForNumericAssertion ( builder.string(assertion.property) } }) + const propertySelectors = options?.propertySelectors + if (propertySelectors !== undefined && propertySelectors.includes(assertion.property)) { + builder.member(ident(assertion.property)) + builder.call(() => {}) + } switch (assertion.comparison) { case 'EQUALS': builder.member(ident('equals')) @@ -44,6 +53,12 @@ export function valueForNumericAssertion ( builder.number(parseInt(assertion.target, 10)) }) break + case 'GREATER_THAN_OR_EQUAL': + builder.member(ident('greaterThanOrEqual')) + builder.call(builder => { + builder.number(parseInt(assertion.target, 10)) + }) + break default: throw new Error(`Unsupported comparison ${assertion.comparison} for assertion source ${assertion.source}`) } @@ -135,6 +150,18 @@ export function valueForGeneralAssertion ( builder.string(assertion.target) }) break + case 'GREATER_THAN_OR_EQUAL': + builder.member(ident('greaterThanOrEqual')) + builder.call(builder => { + builder.string(assertion.target) + }) + break + case 'MATCHES': + builder.member(ident('matches')) + builder.call(builder => { + builder.string(assertion.target) + }) + break case 'CONTAINS': builder.member(ident('contains')) builder.call(builder => { diff --git a/packages/cli/src/constructs/internal/assertion.ts b/packages/cli/src/constructs/internal/assertion.ts index 26d1c8a30..6c12d60d9 100644 --- a/packages/cli/src/constructs/internal/assertion.ts +++ b/packages/cli/src/constructs/internal/assertion.ts @@ -8,9 +8,11 @@ type Comparison = | 'IS_EMPTY' | 'NOT_EMPTY' | 'GREATER_THAN' + | 'GREATER_THAN_OR_EQUAL' | 'LESS_THAN' | 'CONTAINS' | 'NOT_CONTAINS' + | 'MATCHES' | 'IS_NULL' | 'NOT_NULL' @@ -47,6 +49,10 @@ export class NumericAssertionBuilder { + return this._toAssertion('GREATER_THAN_OR_EQUAL', target) + } + /** @private */ private _toAssertion (comparison: Comparison, target: number): Assertion { return { @@ -110,6 +116,10 @@ export class GeneralAssertionBuilder { return this._toAssertion('GREATER_THAN', target) } + greaterThanOrEqual (target: string | number | boolean): Assertion { + return this._toAssertion('GREATER_THAN_OR_EQUAL', target) + } + contains (target: string): Assertion { return this._toAssertion('CONTAINS', target) } @@ -118,6 +128,12 @@ export class GeneralAssertionBuilder { return this._toAssertion('NOT_CONTAINS', target) } + matches (regex: string): Assertion { + // MATCHES carries the regular expression in the `target` field (the runner + // compiles `target` as the pattern), mirroring EQUALS/CONTAINS. + return this._toAssertion('MATCHES', regex) + } + isNull () { return this._toAssertion('IS_NULL') } diff --git a/packages/cli/src/constructs/internal/common-diagnostics.ts b/packages/cli/src/constructs/internal/common-diagnostics.ts index b2f9bc99f..52a5fcdf5 100644 --- a/packages/cli/src/constructs/internal/common-diagnostics.ts +++ b/packages/cli/src/constructs/internal/common-diagnostics.ts @@ -129,6 +129,10 @@ type ResponseTimeProps = { type ResponseTimeLimits = { degradedResponseTime: number maxResponseTime: number + // The per-type default that the backend applies to `maxResponseTime` when the + // caller omits it. Used so the degraded <= max cross-check still runs (against + // the effective max) when only `degradedResponseTime` is set explicitly. + defaultMaxResponseTime?: number } // eslint-disable-next-line require-await @@ -166,4 +170,26 @@ export async function validateResponseTimes ( )) } } + + // When `maxResponseTime` is omitted the backend applies a per-type default, so + // compare `degradedResponseTime` against that effective max. This catches the + // case where an explicit degraded value exceeds the default max (which would + // otherwise pass CLI validation but produce a broken check server-side). + const effectiveMaxResponseTime = props.maxResponseTime ?? limits.defaultMaxResponseTime + if ( + props.degradedResponseTime !== undefined + && effectiveMaxResponseTime !== undefined + && props.degradedResponseTime > effectiveMaxResponseTime + ) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'degradedResponseTime', + new Error( + props.maxResponseTime !== undefined + ? `The value of "degradedResponseTime" must be less than or equal to "maxResponseTime".` + : `The value of "degradedResponseTime" must be less than or equal to the default ` + + `"maxResponseTime" of ${effectiveMaxResponseTime}. Set an explicit "maxResponseTime" ` + + `if you need a higher limit.`, + ), + )) + } } diff --git a/packages/cli/src/constructs/ssl-assertion-codegen.ts b/packages/cli/src/constructs/ssl-assertion-codegen.ts index bce14c81c..b391883e0 100644 --- a/packages/cli/src/constructs/ssl-assertion-codegen.ts +++ b/packages/cli/src/constructs/ssl-assertion-codegen.ts @@ -30,6 +30,12 @@ export function valueForSslAssertion (genfile: GeneratedFile, assertion: SslAsse return valueForGeneralAssertion('SslAssertionBuilder', 'issuerFingerprintSha256', assertion, generalNoArgs) case 'SIGNATURE_ALGORITHM': return valueForGeneralAssertion('SslAssertionBuilder', 'signatureAlgorithm', assertion, generalNoArgs) + case 'OCSP_STAPLED': + return valueForGeneralAssertion('SslAssertionBuilder', 'ocspStapled', assertion, generalNoArgs) + case 'HANDSHAKE_TIME_MS': + return valueForNumericAssertion('SslAssertionBuilder', 'handshakeTimeMs', assertion) + case 'SAN_CONTAINS': + return valueForGeneralAssertion('SslAssertionBuilder', 'sanContains', assertion, generalNoArgs) default: throw new Error(`Unsupported SSL assertion source ${assertion.source}`) } diff --git a/packages/cli/src/constructs/ssl-assertion.ts b/packages/cli/src/constructs/ssl-assertion.ts index d38ea5704..a64e3b309 100644 --- a/packages/cli/src/constructs/ssl-assertion.ts +++ b/packages/cli/src/constructs/ssl-assertion.ts @@ -12,6 +12,9 @@ type SslAssertionSource = | 'ISSUER_FINGERPRINT_SHA256' | 'KEY_SIZE_BITS' | 'SIGNATURE_ALGORITHM' + | 'OCSP_STAPLED' + | 'HANDSHAKE_TIME_MS' + | 'SAN_CONTAINS' export type SslAssertion = CoreAssertion @@ -122,4 +125,30 @@ export class SslAssertionBuilder { static signatureAlgorithm () { return new GeneralAssertionBuilder('SIGNATURE_ALGORITHM') } + + /** + * Creates an assertion builder for whether a stapled OCSP response was + * provided during the handshake. + * @returns A general assertion builder for the OCSP-stapled status. + */ + static ocspStapled () { + return new GeneralAssertionBuilder('OCSP_STAPLED') + } + + /** + * Creates an assertion builder for the TLS handshake time in milliseconds. + * @returns A numeric assertion builder for the handshake time. + */ + static handshakeTimeMs () { + return new NumericAssertionBuilder('HANDSHAKE_TIME_MS') + } + + /** + * Creates an assertion builder for the certificate subject alternative names + * (SANs). + * @returns A general assertion builder for the SAN entries. + */ + static sanContains () { + return new GeneralAssertionBuilder('SAN_CONTAINS') + } } diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index 38b4c6321..85505da60 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -2,7 +2,8 @@ import { Monitor, MonitorProps } from './monitor.js' import { Session } from './session.js' import { Diagnostics } from './diagnostics.js' import { SslRequest } from './ssl-request.js' -import { InvalidPropertyValueDiagnostic, RequiredPropertyDiagnostic } from './construct-diagnostics.js' +import { validateResponseTimes } from './internal/common-diagnostics.js' +import { RequiredPropertyDiagnostic } from './construct-diagnostics.js' export interface SslMonitorProps extends MonitorProps { /** @@ -78,18 +79,13 @@ export class SslMonitor extends Monitor { )) } - if ( - this.degradedResponseTime !== undefined - && this.maxResponseTime !== undefined - && this.degradedResponseTime > this.maxResponseTime - ) { - diagnostics.add(new InvalidPropertyValueDiagnostic( - 'degradedResponseTime', - new Error( - `The value of "degradedResponseTime" must be less than or equal to "maxResponseTime".`, - ), - )) - } + await validateResponseTimes(diagnostics, this, { + degradedResponseTime: 30_000, + maxResponseTime: 30_000, + // Backend default applied when maxResponseTime is omitted (see + // sslMonitorResponseTimeLimitFields in response-time-limit-schema.ts). + defaultMaxResponseTime: 10_000, + }) } synthesize () { diff --git a/packages/cli/src/constructs/ssl-request.ts b/packages/cli/src/constructs/ssl-request.ts index e9ca3791f..0a1c0aea1 100644 --- a/packages/cli/src/constructs/ssl-request.ts +++ b/packages/cli/src/constructs/ssl-request.ts @@ -5,10 +5,10 @@ import { IPFamily } from './ip.js' * The severity applied to an SSL security-baseline rule. * * - `fail` marks the monitor as failing when the rule is violated. - * - `warn` marks the monitor as degraded when the rule is violated. + * - `degrade` marks the monitor as degraded when the rule is violated. * - `ignore` disables the rule. */ -export type SslBaselineSeverity = 'fail' | 'warn' | 'ignore' +export type SslBaselineSeverity = 'fail' | 'degrade' | 'ignore' /** * A baseline rule whose value is a TLS version string (e.g. `TLS1.2`/`TLS1.3`). diff --git a/packages/cli/src/constructs/traceroute-assertion-codegen.ts b/packages/cli/src/constructs/traceroute-assertion-codegen.ts index 76f217ff0..cd17cf356 100644 --- a/packages/cli/src/constructs/traceroute-assertion-codegen.ts +++ b/packages/cli/src/constructs/traceroute-assertion-codegen.ts @@ -7,7 +7,13 @@ export function valueForTracerouteAssertion (genfile: GeneratedFile, assertion: switch (assertion.source) { case 'RESPONSE_TIME': - return valueForNumericAssertion('TracerouteAssertionBuilder', 'responseTime', assertion) + // The forward API selects the response-time property via a chained method + // (`responseTime().max().lessThan(...)`), so emit the property as a + // selector instead of dropping it. An empty/unknown property round-trips + // to a bare `responseTime()` (which defaults to `avg`). + return valueForNumericAssertion('TracerouteAssertionBuilder', 'responseTime', assertion, { + propertySelectors: ['avg', 'min', 'max', 'stdDev'], + }) case 'HOP_COUNT': return valueForNumericAssertion('TracerouteAssertionBuilder', 'hopCount', assertion) case 'PACKET_LOSS': diff --git a/packages/cli/src/constructs/traceroute-assertion.ts b/packages/cli/src/constructs/traceroute-assertion.ts index c625559db..e1d107a42 100644 --- a/packages/cli/src/constructs/traceroute-assertion.ts +++ b/packages/cli/src/constructs/traceroute-assertion.ts @@ -7,15 +7,56 @@ type TracerouteAssertionSource = export type TracerouteAssertion = CoreAssertion +/** + * The statistical property of the traceroute response time to assert against. + * The backend requires one of these for `RESPONSE_TIME` assertions. + */ +export type TracerouteResponseTimeProperty = 'avg' | 'min' | 'max' | 'stdDev' + +/** + * A numeric assertion builder for traceroute response time. It defaults to the + * `avg` property so `responseTime().lessThan(1000)` works out of the box, and + * exposes `avg()`/`min()`/`max()`/`stdDev()` to select a specific property. + */ +class TracerouteResponseTimeAssertionBuilder + extends NumericAssertionBuilder { + constructor () { + super('RESPONSE_TIME', 'avg') + } + + /** Asserts against the average response time. */ + avg () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'avg') + } + + /** Asserts against the minimum response time. */ + min () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'min') + } + + /** Asserts against the maximum response time. */ + max () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'max') + } + + /** Asserts against the standard deviation of the response time. */ + stdDev () { + return new NumericAssertionBuilder('RESPONSE_TIME', 'stdDev') + } +} + /** * Builder class for creating traceroute monitor assertions. * Provides methods to create assertions for traceroute probe responses. * * @example * ```typescript - * // Response time assertions + * // Response time assertions (defaults to the average) * TracerouteAssertionBuilder.responseTime().lessThan(1000) * + * // Response time assertions against a specific property + * TracerouteAssertionBuilder.responseTime().max().lessThan(2000) + * * // Hop count assertions * TracerouteAssertionBuilder.hopCount().lessThan(20) * @@ -25,11 +66,13 @@ export type TracerouteAssertion = CoreAssertion */ export class TracerouteAssertionBuilder { /** - * Creates an assertion builder for traceroute response time. - * @returns A numeric assertion builder for response time in milliseconds. + * Creates an assertion builder for traceroute response time in milliseconds. + * Defaults to the `avg` property; use `avg()`/`min()`/`max()`/`stdDev()` to + * select a specific property. + * @returns A numeric assertion builder for response time. */ static responseTime () { - return new NumericAssertionBuilder('RESPONSE_TIME') + return new TracerouteResponseTimeAssertionBuilder() } /** diff --git a/packages/cli/src/constructs/traceroute-monitor.ts b/packages/cli/src/constructs/traceroute-monitor.ts index a38345cbc..c39e3c9f4 100644 --- a/packages/cli/src/constructs/traceroute-monitor.ts +++ b/packages/cli/src/constructs/traceroute-monitor.ts @@ -78,6 +78,9 @@ export class TracerouteMonitor extends Monitor { await validateResponseTimes(diagnostics, this, { degradedResponseTime: 30_000, maxResponseTime: 30_000, + // Backend default applied when maxResponseTime is omitted (see + // tracerouteResponseTimeLimitFields in response-time-limit-schema.ts). + defaultMaxResponseTime: 20_000, }) } 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__/check-result-detail.spec.ts b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts index 006afc027..55b00a8d2 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,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import logSymbols from 'log-symbols' import { stripAnsi } from '../render.js' + +// log-symbols ships pre-colored (ANSI-wrapped) glyphs; strip them so the +// symbols match the stripped/markdown assertion output. +const PASS = stripAnsi(logSymbols.success) +const FAIL = stripAnsi(logSymbols.error) import { formatResultDetail, groupAttemptsBySequence, @@ -381,6 +387,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 +450,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 +527,130 @@ 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)) + }) }) }) diff --git a/packages/cli/src/formatters/assertion-line.ts b/packages/cli/src/formatters/assertion-line.ts new file mode 100644 index 000000000..9716edf11 --- /dev/null +++ b/packages/cli/src/formatters/assertion-line.ts @@ -0,0 +1,145 @@ +import chalk from 'chalk' +import indentString from 'indent-string' +import logSymbols from 'log-symbols' + +// 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', +} + +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) + // Cap on the stringified length, not `val.length` — objects/numbers/booleans + // have no meaningful `.length`, so the char cap was previously bypassed for + // them. Slice by code point (spread) so we never split a UTF-16 surrogate + // pair (emoji / astral chars) into an invalid half-character. + if (opts.chars && result.length > opts.chars) { + truncated = true + result = [...result].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 +} + +// 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 ? 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) +} diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index c3448ce8a..234b034e7 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -1,4 +1,5 @@ import chalk from 'chalk' +import logSymbols from 'log-symbols' import type { CheckResult, ApiCheckResult, @@ -27,7 +28,9 @@ import { renderCommandHints, renderAdaptiveTable, truncateSingleLine, + stripAnsi, } from './render.js' +import { type AssertionLike, formatAssertionLine } from './assertion-line.js' // --- Helpers --- @@ -697,6 +700,35 @@ 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. +function appendAssertionsTerminal ( + lines: string[], + assertions: Array> | null | undefined, +): void { + if (!assertions || assertions.length === 0) return + lines.push('') + lines.push(heading('ASSERTIONS', 2, 'terminal')) + for (const assertion of assertions) { + lines.push(` ${formatAssertionLine(assertion as AssertionLike)}`) + } +} + +function appendAssertionsMd ( + lines: string[], + assertions: Array> | null | undefined, +): void { + if (!assertions || assertions.length === 0) 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 +743,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 +770,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.assertions) + if (tr.requestError) { lines.push('') lines.push(heading('ERROR', 2, 'terminal')) @@ -770,6 +813,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 +823,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.assertions) + if (tr.requestError) { + lines.push('') + lines.push('## Error') + lines.push(`- ${tr.requestError}`) + } return lines } @@ -837,6 +893,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.assertions) + if (grpc.requestError) { lines.push('') lines.push(heading('ERROR', 2, 'terminal')) @@ -869,13 +941,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.assertions) + 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 ? logSymbols.error : logSymbols.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 = stripAnsi(rule.violated ? logSymbols.error : logSymbols.success) + lines.push(`- ${sym} ${rule.human}${rule.severity ? ` (${rule.severity})` : ''}`) + } +} + function formatSslResultTerminal (ssl: SslCheckResult): string[] { const lines: string[] = [] const resp = ssl.response ?? {} @@ -913,6 +1171,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.assertions) + if (ssl.requestError) { lines.push('') lines.push(heading('ERROR', 2, 'terminal')) @@ -942,7 +1205,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.assertions) + 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..347dfd1bc 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) { @@ -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 go-runner 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