Skip to content
Merged
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
Expand Up @@ -18,10 +18,10 @@ new SslMonitor('example-com-ssl', {
alertDaysBeforeExpiry: 30,
},
assertions: [
SslAssertionBuilder.certExpiresInDays().greaterThan(30),
SslAssertionBuilder.chainTrusted().equals(true),
SslAssertionBuilder.hostnameVerified().equals(true),
SslAssertionBuilder.tlsVersion().equals('TLS1.3'),
SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30),
SslAssertionBuilder.connection('chainTrusted').equals(true),
SslAssertionBuilder.connection('hostnameVerified').equals(true),
SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'),
],
},
})
8 changes: 4 additions & 4 deletions examples/advanced-project/src/__checks__/uptime/ssl.check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ new SslMonitor('example-com-ssl', {
alertDaysBeforeExpiry: 30,
},
assertions: [
SslAssertionBuilder.certExpiresInDays().greaterThan(30),
SslAssertionBuilder.chainTrusted().equals(true),
SslAssertionBuilder.hostnameVerified().equals(true),
SslAssertionBuilder.tlsVersion().equals('TLS1.3'),
SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30),
SslAssertionBuilder.connection('chainTrusted').equals(true),
SslAssertionBuilder.connection('hostnameVerified').equals(true),
SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'),
],
},
})
8 changes: 4 additions & 4 deletions packages/cli/src/ai-context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,10 @@ new SslMonitor('example-com-ssl', {
alertDaysBeforeExpiry: 30,
},
assertions: [
SslAssertionBuilder.certExpiresInDays().greaterThan(30),
SslAssertionBuilder.chainTrusted().equals(true),
SslAssertionBuilder.hostnameVerified().equals(true),
SslAssertionBuilder.tlsVersion().equals('TLS1.3'),
SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(30),
SslAssertionBuilder.connection('chainTrusted').equals(true),
SslAssertionBuilder.connection('hostnameVerified').equals(true),
SslAssertionBuilder.connection('tlsVersion').equals('TLS1.3'),
],
},
})
Expand Down
113 changes: 99 additions & 14 deletions packages/cli/src/constructs/__tests__/ssl-assertion-codegen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,119 @@ function render (assertion: SslAssertion): string {
}

describe('SSL Assertion Codegen', () => {
it('generates the new SSL assertion sources', () => {
it('generates the property-scoped SSL assertion sources', () => {
const cases: { input: SslAssertion, expected: string }[] = [
// Boolean sources emit a boolean literal, not a quoted string.
// CERTIFICATE / CONNECTION emit the property name as the first call argument.
// Numeric properties emit an unquoted number, matching the builder's typed target.
{
input: { source: 'OCSP_STAPLED', property: '', comparison: 'EQUALS', target: 'true', regex: null },
expected: 'SslAssertionBuilder.ocspStapled().equals(true)\n',
input: { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: '30', regex: null },
expected: 'SslAssertionBuilder.certificate(\'daysUntilExpiry\').greaterThan(30)\n',
},
{
input: { source: 'CERT_NOT_EXPIRED', property: '', comparison: 'EQUALS', target: 'true', regex: null },
expected: 'SslAssertionBuilder.certNotExpired().equals(true)\n',
input: { source: 'CERTIFICATE', property: 'signatureAlgorithm', comparison: 'EQUALS', target: 'SHA256-RSA', regex: null },
expected: 'SslAssertionBuilder.certificate(\'signatureAlgorithm\').equals(\'SHA256-RSA\')\n',
},
// Boolean properties emit a boolean literal, not a quoted string.
{
input: { source: 'CHAIN_TRUSTED', property: '', comparison: 'EQUALS', target: 'false', regex: null },
expected: 'SslAssertionBuilder.chainTrusted().equals(false)\n',
input: { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'false', regex: null },
expected: 'SslAssertionBuilder.certificate(\'selfSigned\').equals(false)\n',
},
// Numeric key size emits an unquoted number.
{
input: { source: 'KEY_SIZE_BITS', property: '', comparison: 'EQUALS', target: '2048', regex: null },
expected: 'SslAssertionBuilder.keySizeBits().equals(2048)\n',
input: { source: 'CONNECTION', property: 'chainTrusted', comparison: 'EQUALS', target: 'true', regex: null },
expected: 'SslAssertionBuilder.connection(\'chainTrusted\').equals(true)\n',
},
// The string sources support the MATCHES (regex) operator.
{
input: { source: 'CIPHER_SUITE', property: '', comparison: 'MATCHES', target: 'TLS_(AES|CHACHA)', regex: null },
expected: 'SslAssertionBuilder.cipherSuite().matches(\'TLS_(AES|CHACHA)\')\n',
input: { source: 'CONNECTION', property: 'tlsVersion', comparison: 'EQUALS', target: 'TLS1.3', regex: null },
expected: 'SslAssertionBuilder.connection(\'tlsVersion\').equals(\'TLS1.3\')\n',
},
// RESPONSE_TIME is numeric — the target is an unquoted number and no property is emitted.
{
input: { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '1000', regex: null },
expected: 'SslAssertionBuilder.responseTime().lessThan(1000)\n',
},
// JSON_RESPONSE emits the JSONPath property.
{
input: { source: 'JSON_RESPONSE', property: '$.status', comparison: 'EQUALS', target: 'ok', regex: null },
expected: 'SslAssertionBuilder.jsonResponse(\'$.status\').equals(\'ok\')\n',
},
// TEXT_RESPONSE carries its regex in the property slot (the backend/runner contract).
{
input: { source: 'TEXT_RESPONSE', property: '', comparison: 'CONTAINS', target: 'healthy', regex: null },
expected: 'SslAssertionBuilder.textResponse().contains(\'healthy\')\n',
},
{
input: { source: 'TEXT_RESPONSE', property: 'status: (\\w+)', comparison: 'EQUALS', target: 'ok', regex: null },
expected: 'SslAssertionBuilder.textResponse(\'status: (\\\\w+)\').equals(\'ok\')\n',
},
]
for (const test of cases) {
expect(render(test.input)).toEqual(test.expected)
}
})

// The backend accepts any target string, so a monitor created via the UI or API can
// carry a target the builder's typed operators cannot express. Rendering it as a typed
// literal anyway would rewrite the assertion on the next deploy ('yes' becoming false,
// '30.5' becoming 30), so these fall back to the string form, which preserves the value.
it('does not coerce a target its typed operators cannot express', () => {
const cases: { input: SslAssertion, expected: string }[] = [
{
input: { source: 'CERTIFICATE', property: 'selfSigned', comparison: 'EQUALS', target: 'yes', regex: null },
expected: 'SslAssertionBuilder.certificate(\'selfSigned\').equals(\'yes\')\n',
},
{
input: { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '', regex: null },
expected: 'SslAssertionBuilder.certificate(\'keySizeBits\').equals(\'\')\n',
},
{
input: { source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target: 'abc', regex: null },
expected: 'SslAssertionBuilder.certificate(\'daysUntilExpiry\').greaterThan(\'abc\')\n',
},
{
// parseInt would take the leading prefix and silently assert against 5.
input: { source: 'CERTIFICATE', property: 'keySizeBits', comparison: 'EQUALS', target: '5%', regex: null },
expected: 'SslAssertionBuilder.certificate(\'keySizeBits\').equals(\'5%\')\n',
},
]
for (const test of cases) {
expect(render(test.input)).toEqual(test.expected)
}
})

// Codegen must render every target validateSslAssertion accepts as a numeric literal:
// the property's operators take a number, so a quoted target would not compile. parseInt
// would truncate the fractional and exponent forms.
it('renders every numeric target validation accepts as a number literal', () => {
const cases: { target: string, expected: string }[] = [
{ target: '30', expected: '30' },
{ target: '30.5', expected: '30.5' },
{ target: '0.5', expected: '0.5' },
{ target: '1e3', expected: '1000' },
{ target: '-5', expected: '-5' },
{ target: ' 30 ', expected: '30' },
]
for (const { target, expected } of cases) {
expect(render(
{ source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'GREATER_THAN', target, regex: null },
)).toEqual(`SslAssertionBuilder.certificate('daysUntilExpiry').greaterThan(${expected})\n`)
}
})

// A comparison outside a typed helper's set makes the helper throw, which would abort
// the whole import over one assertion.
it('renders rather than throws on a comparison the property does not support', () => {
expect(render(
{ source: 'CERTIFICATE', property: 'daysUntilExpiry', comparison: 'CONTAINS', target: '30', regex: null },
)).toEqual('SslAssertionBuilder.certificate(\'daysUntilExpiry\').contains(\'30\')\n')
expect(render(
{ source: 'CERTIFICATE', property: 'selfSigned', comparison: 'NOT_EQUALS', target: 'true', regex: null },
)).toEqual('SslAssertionBuilder.certificate(\'selfSigned\').notEquals(\'true\')\n')
})

// An unknown property reaches codegen from remote data; validation reports it.
it('renders an unknown property rather than throwing', () => {
expect(render(
{ source: 'CERTIFICATE', property: 'bogusProperty', comparison: 'EQUALS', target: 'x', regex: null },
)).toEqual('SslAssertionBuilder.certificate(\'bogusProperty\').equals(\'x\')\n')
})
})
122 changes: 122 additions & 0 deletions packages/cli/src/constructs/__tests__/ssl-assertion-grammar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'

import { Diagnostics } from '../diagnostics.js'
import { operatorComparisons } from '../internal/assertion-grammar.js'
import { certificateGrammar, connectionGrammar } from '../ssl-assertion-grammar.js'
import { booleanComparisons, numericComparisons, valueForSslAssertion } from '../ssl-assertion-codegen.js'
import { validateSslAssertion } from '../ssl-assertion-validation.js'
import { SslAssertion, SslAssertionBuilder } from '../ssl-assertion.js'
import { GeneratedFile, Output } from '../../sourcegen/index.js'

// The grammar tables in internal/ssl-grammar.ts are the single declaration the builder,
// the validation whitelist, and codegen are all derived from. These tests iterate that
// data and assert the three consumers agree for every declared (property, operator), so a
// drift between them cannot land — replacing the per-class synchronization the previous
// design could not hold by hand.

function render (assertion: SslAssertion): string {
const output = new Output()
valueForSslAssertion(new GeneratedFile('foo.ts'), assertion).render(output)
return output.finalize()
}

// A runtime target of the right shape for a property's value kind. The builder stringifies
// it, so any in-kind value round-trips; validation then accepts it.
function sampleTarget (kind: string): string | number | boolean {
switch (kind) {
case 'number':
return 1
case 'boolean':
return true
default:
return 'x'
}
}

const sources = [
{ source: 'CERTIFICATE', method: 'certificate', grammar: certificateGrammar },
{ source: 'CONNECTION', method: 'connection', grammar: connectionGrammar },
] as const

describe('SSL assertion grammar', () => {
it('routes every declared operator through the public builder to the right wire fields', () => {
for (const { source, method, grammar } of sources) {
for (const [property, decl] of Object.entries(grammar)) {
for (const operator of decl.operators) {
type Builder = Record<string, (t: unknown) => SslAssertion>
const builder = (SslAssertionBuilder[method] as (p: string) => Builder)(property)
const assertion = builder[operator](sampleTarget(decl.target.kind))
expect(assertion).toMatchObject({
source,
property,
comparison: operatorComparisons[operator],
})
}
}
}
})

it('accepts every declared (property, operator) through validation', () => {
for (const { source, grammar } of sources) {
for (const [property, decl] of Object.entries(grammar)) {
for (const operator of decl.operators) {
const assertion: SslAssertion = {
source,
property,
comparison: operatorComparisons[operator],
target: String(sampleTarget(decl.target.kind)),
regex: null,
}
const diags = new Diagnostics()
validateSslAssertion(diags, assertion, 0)
expect(diags.isFatal(), `${source}.${property}.${operator} should validate`).toEqual(false)
}
}
}
})

it('renders every declared (property, operator) naming the same property and operator', () => {
for (const { method, grammar } of sources) {
for (const [property, decl] of Object.entries(grammar)) {
for (const operator of decl.operators) {
const assertion: SslAssertion = {
source: method === 'certificate' ? 'CERTIFICATE' : 'CONNECTION',
property,
comparison: operatorComparisons[operator],
target: String(sampleTarget(decl.target.kind)),
regex: null,
}
const source = render(assertion)
expect(source).toContain(`SslAssertionBuilder.${method}('${property}')`)
expect(source).toContain(`.${operator}(`)
}
}
}
})

// Codegen renders a number target as a bare number literal and a boolean as a bare
// boolean, both only for the comparisons it has typed paths for. A property of that kind
// declaring any other operator would fall through to the quoted-string form, which does
// not compile against the property's typed builder method — the drift this guards against.
//
// The renderable sets are codegen's own predicates (numericComparisons / booleanComparisons),
// not restated here, so this cannot fall out of step with what codegen actually renders.
it('lets number and boolean properties declare only operators codegen renders as literals', () => {
const renderableFor: Record<string, Record<string, true>> = {
number: numericComparisons,
boolean: booleanComparisons,
}
for (const { grammar } of sources) {
for (const [property, decl] of Object.entries(grammar)) {
const renderable = renderableFor[decl.target.kind]
if (renderable === undefined) {
continue
}
for (const operator of decl.operators) {
const comparison = operatorComparisons[operator]
expect(Object.hasOwn(renderable, comparison), `${property}.${operator} is not rendered as a ${decl.target.kind} literal`).toBe(true)
}
}
}
})
})
Loading
Loading