Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
87 changes: 87 additions & 0 deletions packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
})
})
53 changes: 53 additions & 0 deletions packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/constructs/grpc-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export class GrpcMonitor extends Monitor {
await validateResponseTimes(diagnostics, this, {
degradedResponseTime: 30_000,
maxResponseTime: 30_000,
// Backend default applied when maxResponseTime is omitted (see
// grpcResponseTimeLimitFields in response-time-limit-schema.ts).
defaultMaxResponseTime: 20_000,
})
}

Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/constructs/internal/assertion-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Source extends string> (
Expand All @@ -19,6 +23,11 @@ export function valueForNumericAssertion<Source extends string> (
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'))
Expand All @@ -44,6 +53,12 @@ export function valueForNumericAssertion<Source extends string> (
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}`)
}
Expand Down Expand Up @@ -135,6 +150,18 @@ export function valueForGeneralAssertion<Source extends string> (
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 => {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/constructs/internal/assertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -47,6 +49,10 @@ export class NumericAssertionBuilder<Source extends string, Property extends str
return this._toAssertion('GREATER_THAN', target)
}

greaterThanOrEqual (target: number): Assertion<Source> {
return this._toAssertion('GREATER_THAN_OR_EQUAL', target)
}

/** @private */
private _toAssertion (comparison: Comparison, target: number): Assertion<Source> {
return {
Expand Down Expand Up @@ -110,6 +116,10 @@ export class GeneralAssertionBuilder<Source extends string> {
return this._toAssertion('GREATER_THAN', target)
}

greaterThanOrEqual (target: string | number | boolean): Assertion<Source> {
return this._toAssertion('GREATER_THAN_OR_EQUAL', target)
}

contains (target: string): Assertion<Source> {
return this._toAssertion('CONTAINS', target)
}
Expand All @@ -118,6 +128,12 @@ export class GeneralAssertionBuilder<Source extends string> {
return this._toAssertion('NOT_CONTAINS', target)
}

matches (regex: string): Assertion<Source> {
// 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')
}
Expand Down
Loading