diff --git a/packages/cli/src/constructs/__tests__/dns-assertion.spec.ts b/packages/cli/src/constructs/__tests__/dns-assertion.spec.ts new file mode 100644 index 000000000..7460953df --- /dev/null +++ b/packages/cli/src/constructs/__tests__/dns-assertion.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' + +import { DnsAssertionBuilder } from '../dns-assertion' + +describe('DnsAssertionBuilder', () => { + describe('answer assertions', () => { + it('answerData carries source ANSWER, property data and the quantifier', () => { + expect(DnsAssertionBuilder.answerData('EVERY').equals('192.0.2.1')).toEqual({ + source: 'ANSWER', + property: 'data', + quantifier: 'EVERY', + comparison: 'EQUALS', + target: '192.0.2.1', + regex: null, + }) + }) + + it('answerName / answerType set the matching property', () => { + expect(DnsAssertionBuilder.answerName('SOME').equals('acme.com.')).toMatchObject({ + source: 'ANSWER', + property: 'name', + quantifier: 'SOME', + }) + expect(DnsAssertionBuilder.answerType('NONE').equals('CNAME')).toMatchObject({ + source: 'ANSWER', + property: 'type', + quantifier: 'NONE', + }) + }) + + it('answerTtl is numeric and quantifier-aware', () => { + expect(DnsAssertionBuilder.answerTtl('EVERY').greaterThan(300)).toEqual({ + source: 'ANSWER', + property: 'ttl', + quantifier: 'EVERY', + comparison: 'GREATER_THAN', + target: '300', + regex: null, + }) + }) + + it('answerCount is numeric and carries NO quantifier', () => { + const assertion = DnsAssertionBuilder.answerCount().greaterThan(0) + expect(assertion).toEqual({ + source: 'ANSWER', + property: 'count', + comparison: 'GREATER_THAN', + target: '0', + regex: null, + }) + expect('quantifier' in assertion).toBe(false) + }) + + it('answerData supports matches / notMatches (regex comparators)', () => { + expect(DnsAssertionBuilder.answerData('SOME').matches('^10\\.')).toMatchObject({ + source: 'ANSWER', + property: 'data', + quantifier: 'SOME', + comparison: 'MATCHES', + target: '^10\\.', + }) + expect(DnsAssertionBuilder.answerData('NONE').notMatches('^192\\.')).toMatchObject({ + comparison: 'NOT_MATCHES', + quantifier: 'NONE', + }) + }) + }) + + describe('matches / notMatches on the shared general builder', () => { + it('are available on textAnswer / jsonAnswer and omit a quantifier', () => { + const textAssertion = DnsAssertionBuilder.textAnswer().matches('foo.*') + expect(textAssertion).toMatchObject({ + source: 'TEXT_ANSWER', + comparison: 'MATCHES', + target: 'foo.*', + }) + expect('quantifier' in textAssertion).toBe(false) + + expect(DnsAssertionBuilder.jsonAnswer('$.Answer[0].data').notMatches('bar')) + .toMatchObject({ + source: 'JSON_ANSWER', + comparison: 'NOT_MATCHES', + target: 'bar', + }) + }) + }) + + describe('existing assertions are unchanged', () => { + it('responseCode still emits no quantifier key', () => { + const assertion = DnsAssertionBuilder.responseCode().equals('NOERROR') + expect(assertion).toEqual({ + source: 'RESPONSE_CODE', + property: '', + comparison: 'EQUALS', + target: 'NOERROR', + regex: null, + }) + expect('quantifier' in assertion).toBe(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/dns-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/dns-monitor-codegen.spec.ts new file mode 100644 index 000000000..3b88226ca --- /dev/null +++ b/packages/cli/src/constructs/__tests__/dns-monitor-codegen.spec.ts @@ -0,0 +1,168 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { DnsMonitorCodegen, DnsMonitorResource } from '../dns-monitor-codegen' +import { Context } from '../internal/codegen/context' +import { Program } from '../../sourcegen' + +interface RenderEnv { + rootDirectory: string + cleanup: () => Promise +} + +async function createRenderEnv (): Promise { + const rootDirectory = await mkdtemp(path.join(tmpdir(), 'dns-codegen-')) + return { + rootDirectory, + cleanup: () => rm(rootDirectory, { recursive: true, force: true }), + } +} + +async function renderResource (env: RenderEnv, resource: DnsMonitorResource): Promise { + const program = new Program({ + rootDirectory: env.rootDirectory, + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + }) + + const codegen = new DnsMonitorCodegen(program) + const context = new Context() + + codegen.gencode(resource.id, resource, context) + + await program.realize() + + const [filePath] = program.paths + if (filePath === undefined) { + throw new Error('DnsMonitorCodegen did not register any generated files') + } + + return readFile(filePath, 'utf8') +} + +const baseResource = (request: DnsMonitorResource['request']): DnsMonitorResource => ({ + id: 'dns-monitor', + checkType: 'DNS', + name: 'DNS Monitor', + request, +}) + +describe('DnsMonitorCodegen', () => { + let env: RenderEnv + + beforeEach(async () => { + env = await createRenderEnv() + }) + + afterEach(async () => { + await env.cleanup() + }) + + it('should roundtrip a new record type and dnsConfig', async () => { + const source = await renderResource(env, baseResource({ + recordType: 'SRV', + query: '_sip._tcp.example.com', + dnsConfig: { + queryTimeoutSeconds: 3, + followCname: true, + }, + })) + + expect(source).toContain('new DnsMonitor(\'dns-monitor\'') + expect(source).toContain('recordType: \'SRV\'') + expect(source).toContain('dnsConfig: {') + expect(source).toContain('queryTimeoutSeconds: 3') + expect(source).toContain('followCname: true') + }) + + it('should roundtrip dnsConfig.changeDetection', async () => { + const source = await renderResource(env, baseResource({ + recordType: 'A', + query: 'acme.com', + dnsConfig: { + changeDetection: { + enabled: true, + includeTtl: true, + }, + }, + })) + + expect(source).toContain('dnsConfig: {') + expect(source).toContain('changeDetection: {') + expect(source).toContain('enabled: true') + expect(source).toContain('includeTtl: true') + }) + + it('should omit includeTtl from changeDetection when absent', async () => { + const source = await renderResource(env, baseResource({ + recordType: 'A', + query: 'acme.com', + dnsConfig: { + changeDetection: { + enabled: false, + }, + }, + })) + + expect(source).toContain('changeDetection: {') + expect(source).toContain('enabled: false') + expect(source).not.toContain('includeTtl') + }) + + it('should omit changeDetection when absent', async () => { + const source = await renderResource(env, baseResource({ + recordType: 'A', + query: 'acme.com', + dnsConfig: { + followCname: true, + }, + })) + + expect(source).not.toContain('changeDetection') + }) + + it('should roundtrip ANSWER assertions with quantifiers and matches', async () => { + const source = await renderResource(env, baseResource({ + recordType: 'A', + query: 'acme.com', + assertions: [ + { source: 'ANSWER', property: 'data', quantifier: 'EVERY', comparison: 'EQUALS', target: '192.0.2.1', regex: null }, + { source: 'ANSWER', property: 'data', quantifier: 'SOME', comparison: 'MATCHES', target: '^10\\.', regex: null }, + { source: 'ANSWER', property: 'ttl', quantifier: 'EVERY', comparison: 'GREATER_THAN', target: '300', regex: null }, + { source: 'ANSWER', property: 'count', comparison: 'GREATER_THAN', target: '0', regex: null }, + ], + })) + + expect(source).toContain('DnsAssertionBuilder.answerData(\'EVERY\').equals(\'192.0.2.1\')') + expect(source).toContain('DnsAssertionBuilder.answerData(\'SOME\').matches(\'^10\\\\.\')') + expect(source).toContain('DnsAssertionBuilder.answerTtl(\'EVERY\').greaterThan(300)') + expect(source).toContain('DnsAssertionBuilder.answerCount().greaterThan(0)') + }) + + it('should roundtrip matches / notMatches on the shared general builder', async () => { + const source = await renderResource(env, baseResource({ + recordType: 'TXT', + query: 'acme.com', + assertions: [ + { source: 'TEXT_ANSWER', property: '', comparison: 'MATCHES', target: 'v=spf1.*', regex: null }, + { source: 'JSON_ANSWER', property: '$.Answer[0].data', comparison: 'NOT_MATCHES', target: 'bad', regex: null }, + ], + })) + + expect(source).toContain('DnsAssertionBuilder.textAnswer().matches(\'v=spf1.*\')') + expect(source).toContain('DnsAssertionBuilder.jsonAnswer(\'$.Answer[0].data\').notMatches(\'bad\')') + }) + + it('should omit dnsConfig when absent (v1-shaped request)', async () => { + const source = await renderResource(env, baseResource({ + recordType: 'A', + query: 'acme.com', + })) + + expect(source).not.toContain('dnsConfig') + }) +}) diff --git a/packages/cli/src/constructs/__tests__/dns-monitor.spec.ts b/packages/cli/src/constructs/__tests__/dns-monitor.spec.ts index ec5c20538..ad0c132aa 100644 --- a/packages/cli/src/constructs/__tests__/dns-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/dns-monitor.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { DnsMonitor, CheckGroup, DnsRequest, Diagnostics } from '../index' +import { DnsMonitor, CheckGroup, DnsRequest, DnsAssertionBuilder, Diagnostics } from '../index' import { Project, Session } from '../project' import { Bundler } from '../../services/check-parser/bundler' @@ -257,5 +257,177 @@ describe('DnsMonitor', () => { expect(diags.isFatal()).toEqual(false) }) + + it('should error if an IP-shaped query is used with a non-PTR record type', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + recordType: 'A', + query: '192.0.2.1', + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('may only be an IP address when "recordType" is "PTR"'), + }), + ])) + }) + + it('should not error if an IP-shaped query is used with a PTR record type', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + recordType: 'PTR', + query: '192.0.2.1', + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(false) + }) + + it('should error if the query is not ASCII', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + recordType: 'A', + query: 'münchen.example.com', + }, + }) + + 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 ASCII'), + }), + ])) + }) + + it.each([1, 30])('should not error if queryTimeoutSeconds is %i', async queryTimeoutSeconds => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + dnsConfig: { queryTimeoutSeconds }, + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(false) + }) + + it.each([0, 31, 5.5])('should error if queryTimeoutSeconds is %s', async queryTimeoutSeconds => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + ...request, + dnsConfig: { queryTimeoutSeconds }, + }, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('"queryTimeoutSeconds" must be an integer between 1 and 30'), + }), + ])) + }) + }) + + describe('synthesize', () => { + it('should include dnsConfig and ANSWER assertions in the request', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + recordType: 'SRV', + query: '_sip._tcp.example.com', + dnsConfig: { queryTimeoutSeconds: 3, followCname: true }, + assertions: [ + DnsAssertionBuilder.answerData('EVERY').matches('.*example.com.'), + DnsAssertionBuilder.answerCount().greaterThan(0), + ], + }, + }) + + expect(check.synthesize()).toMatchObject({ + checkType: 'DNS', + request: { + recordType: 'SRV', + dnsConfig: { queryTimeoutSeconds: 3, followCname: true }, + assertions: [ + { source: 'ANSWER', property: 'data', quantifier: 'EVERY', comparison: 'MATCHES' }, + { source: 'ANSWER', property: 'count', comparison: 'GREATER_THAN', target: '0' }, + ], + }, + }) + }) + + it('should carry dnsConfig.changeDetection through the request', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new DnsMonitor('test-check', { + name: 'Test Check', + request: { + recordType: 'A', + query: 'acme.com', + dnsConfig: { changeDetection: { enabled: true, includeTtl: true } }, + }, + }) + + expect(check.synthesize()).toMatchObject({ + checkType: 'DNS', + request: { + dnsConfig: { changeDetection: { enabled: true, includeTtl: true } }, + }, + }) + }) }) }) diff --git a/packages/cli/src/constructs/dns-assertion-codegen.ts b/packages/cli/src/constructs/dns-assertion-codegen.ts index 07c5baaf7..d7aafcb97 100644 --- a/packages/cli/src/constructs/dns-assertion-codegen.ts +++ b/packages/cli/src/constructs/dns-assertion-codegen.ts @@ -23,7 +23,44 @@ export function valueForDnsAssertion (genfile: GeneratedFile, assertion: DnsAsse hasProperty: true, hasRegex: false, }) + case 'ANSWER': + return valueForDnsAnswerAssertion(assertion) default: throw new Error(`Unsupported DNS assertion source ${assertion.source}`) } } + +// The `answer*` builders encode the record property in the method name +// (`answerData`/`answerTtl`/…) and take the quantifier as their argument (all +// but `answerCount`). Reuse the shared general/numeric emitters by carrying the +// quantifier through the `property` slot they already render as the first call +// argument. +const DNS_ANSWER_METHODS: Record = { + data: { method: 'answerData', numeric: false }, + name: { method: 'answerName', numeric: false }, + type: { method: 'answerType', numeric: false }, + ttl: { method: 'answerTtl', numeric: true }, + count: { method: 'answerCount', numeric: true }, +} + +function valueForDnsAnswerAssertion (assertion: DnsAssertion): Value { + const mapping = DNS_ANSWER_METHODS[assertion.property] + if (mapping === undefined) { + throw new Error(`Unsupported DNS ANSWER assertion property ${assertion.property}`) + } + + // `count` carries no quantifier; every other property requires one. + const quantifier = assertion.quantifier ?? '' + const synthetic = { ...assertion, property: quantifier } + + if (mapping.numeric) { + return valueForNumericAssertion('DnsAssertionBuilder', mapping.method, synthetic, { + hasProperty: quantifier !== '', + }) + } + + return valueForGeneralAssertion('DnsAssertionBuilder', mapping.method, synthetic, { + hasProperty: quantifier !== '', + hasRegex: false, + }) +} diff --git a/packages/cli/src/constructs/dns-assertion.ts b/packages/cli/src/constructs/dns-assertion.ts index ef2d37f63..4864d4a33 100644 --- a/packages/cli/src/constructs/dns-assertion.ts +++ b/packages/cli/src/constructs/dns-assertion.ts @@ -5,9 +5,20 @@ type DnsAssertionSource = | 'RESPONSE_TIME' | 'TEXT_ANSWER' | 'JSON_ANSWER' + | 'ANSWER' export type DnsAssertion = CoreAssertion +/** + * Quantifier for `ANSWER` assertions — determines how the comparison is + * applied across the answer records: + * + * - `EVERY`: the comparison must hold for every record (fails on zero records). + * - `SOME`: the comparison must hold for at least one record. + * - `NONE`: the comparison must hold for no record. + */ +export type DnsAssertionQuantifier = 'EVERY' | 'SOME' | 'NONE' + /** * Builder class for creating DNS monitor assertions. * Provides methods to create assertions for DNS query responses. @@ -59,4 +70,71 @@ export class DnsAssertionBuilder { static jsonAnswer (property?: string) { return new GeneralAssertionBuilder('JSON_ANSWER', property) } + + /** + * Creates an assertion builder for the `data` (record value) of the answer + * records, evaluated across the records with the given quantifier. + * + * @param quantifier `EVERY`, `SOME`, or `NONE`. + * @returns A general assertion builder for the answer `data`. + * @example + * ```typescript + * DnsAssertionBuilder.answerData('EVERY').equals('192.0.2.1') + * DnsAssertionBuilder.answerData('SOME').matches('^10\\.') + * ``` + */ + static answerData (quantifier: DnsAssertionQuantifier) { + return new GeneralAssertionBuilder('ANSWER', 'data', undefined, quantifier) + } + + /** + * Creates an assertion builder for the `name` (owner name) of the answer + * records, evaluated across the records with the given quantifier. + * + * @param quantifier `EVERY`, `SOME`, or `NONE`. + * @returns A general assertion builder for the answer `name`. + */ + static answerName (quantifier: DnsAssertionQuantifier) { + return new GeneralAssertionBuilder('ANSWER', 'name', undefined, quantifier) + } + + /** + * Creates an assertion builder for the `type` (record type) of the answer + * records, evaluated across the records with the given quantifier. + * + * @param quantifier `EVERY`, `SOME`, or `NONE`. + * @returns A general assertion builder for the answer `type`. + */ + static answerType (quantifier: DnsAssertionQuantifier) { + return new GeneralAssertionBuilder('ANSWER', 'type', undefined, quantifier) + } + + /** + * Creates an assertion builder for the `ttl` of the answer records, + * evaluated across the records with the given quantifier. + * + * @param quantifier `EVERY`, `SOME`, or `NONE`. + * @returns A numeric assertion builder for the answer `ttl`. + * @example + * ```typescript + * DnsAssertionBuilder.answerTtl('EVERY').greaterThan(300) + * ``` + */ + static answerTtl (quantifier: DnsAssertionQuantifier) { + return new NumericAssertionBuilder('ANSWER', 'ttl', quantifier) + } + + /** + * Creates an assertion builder for the number of answer records. Compared + * numerically over the whole answer set, so no quantifier is used. + * + * @returns A numeric assertion builder for the answer record count. + * @example + * ```typescript + * DnsAssertionBuilder.answerCount().greaterThan(0) + * ``` + */ + static answerCount () { + return new NumericAssertionBuilder('ANSWER', 'count') + } } diff --git a/packages/cli/src/constructs/dns-monitor.ts b/packages/cli/src/constructs/dns-monitor.ts index dc7e4ab2f..ab9319026 100644 --- a/packages/cli/src/constructs/dns-monitor.ts +++ b/packages/cli/src/constructs/dns-monitor.ts @@ -1,9 +1,11 @@ +import { isIP } from 'node:net' + import { Monitor, MonitorProps } from './monitor' import { Session } from './project' import { Diagnostics } from './diagnostics' import { validateResponseTimes } from './internal/common-diagnostics' import { DnsRequest } from './dns-request' -import { RequiredPropertyDiagnostic } from './construct-diagnostics' +import { InvalidPropertyValueDiagnostic, RequiredPropertyDiagnostic } from './construct-diagnostics' export interface DnsMonitorProps extends MonitorProps { /** @@ -98,6 +100,40 @@ export class DnsMonitor extends Monitor { )) } + // Mirror the backend cross-field rules (validate-dns-query.ts): an IP-shaped + // query is only meaningful for a PTR (reverse) lookup, and the query must be + // ASCII (internationalized domain names have to be punycode-encoded). + if (isIP(this.request.query) !== 0 && this.request.recordType !== 'PTR') { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'query', + new Error( + `A "query" may only be an IP address when "recordType" is "PTR".`, + ), + )) + } + + // eslint-disable-next-line no-control-regex + if (/[^\x00-\x7F]/.test(this.request.query)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'query', + new Error( + `A "query" must be ASCII; encode internationalized domain names as punycode (xn--).`, + ), + )) + } + + // Mirror the backend `queryTimeoutSeconds` bound (integer, 1..30). + const queryTimeoutSeconds = this.request.dnsConfig?.queryTimeoutSeconds + if (queryTimeoutSeconds !== undefined + && (!Number.isInteger(queryTimeoutSeconds) || queryTimeoutSeconds < 1 || queryTimeoutSeconds > 30)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'queryTimeoutSeconds', + new Error( + `A "queryTimeoutSeconds" must be an integer between 1 and 30.`, + ), + )) + } + await validateResponseTimes(diagnostics, this, { degradedResponseTime: 5_000, maxResponseTime: 5_000, diff --git a/packages/cli/src/constructs/dns-request-codegen.ts b/packages/cli/src/constructs/dns-request-codegen.ts index c293355e2..2862fab1e 100644 --- a/packages/cli/src/constructs/dns-request-codegen.ts +++ b/packages/cli/src/constructs/dns-request-codegen.ts @@ -25,6 +25,30 @@ export function valueForDnsRequest ( builder.string('protocol', request.protocol) } + if (request.dnsConfig) { + const dnsConfig = request.dnsConfig + builder.object('dnsConfig', builder => { + if (dnsConfig.queryTimeoutSeconds !== undefined) { + builder.number('queryTimeoutSeconds', dnsConfig.queryTimeoutSeconds) + } + + if (dnsConfig.followCname !== undefined) { + builder.boolean('followCname', dnsConfig.followCname) + } + + if (dnsConfig.changeDetection !== undefined) { + const changeDetection = dnsConfig.changeDetection + builder.object('changeDetection', builder => { + builder.boolean('enabled', changeDetection.enabled) + + if (changeDetection.includeTtl !== undefined) { + builder.boolean('includeTtl', changeDetection.includeTtl) + } + }) + } + }) + } + if (request.assertions) { const assertions = request.assertions if (assertions.length > 0) { diff --git a/packages/cli/src/constructs/dns-request.ts b/packages/cli/src/constructs/dns-request.ts index 3a933f5da..a3a9cdf8c 100644 --- a/packages/cli/src/constructs/dns-request.ts +++ b/packages/cli/src/constructs/dns-request.ts @@ -8,11 +8,75 @@ export type DnsRecordType = | 'NS' | 'TXT' | 'SOA' + | 'HTTPS' + | 'PTR' + | 'SRV' + | 'CAA' + | 'DS' + | 'DNSKEY' + | 'TLSA' + | 'NAPTR' export type DnsProtocol = | 'UDP' | 'TCP' +/** + * Record change-detection settings for DNS requests. When enabled, the monitor + * snapshots the normalized answer records on each run and raises a change event + * when they differ from the confirmed baseline. + */ +export interface DnsChangeDetection { + /** + * Whether record change-detection is enabled. When omitted or `false`, the + * monitor behaves as a plain v1 DNS check with no snapshotting. + * + * @default false + */ + enabled: boolean + + /** + * When `true`, TTL values are included in the normalized snapshot comparison, + * so a TTL-only change is treated as a record change. When omitted, TTL is + * ignored and only the record set (name/type/data) is compared. + * + * @default false + */ + includeTtl?: boolean +} + +/** + * Advanced resolver configuration for DNS requests. All fields are optional; + * omitted fields fall back to the backend defaults. + */ +export interface DnsConfig { + /** + * The maximum time in seconds to wait for each DNS query attempt (applied + * per attempt in the resolver failover loop). + * + * @minimum 1 + * @maximum 30 + * @default 5 + */ + queryTimeoutSeconds?: number + + /** + * When `true` and `recordType` is not `CNAME`, a CNAME-only answer for the + * query name is followed to its canonical target (bounded depth, loop + * detection, shared query-timeout budget) and the final answer feeds the + * assertions. + * + * @default false + */ + followCname?: boolean + + /** + * Record change-detection settings. Optional — omit (or set `enabled: false`) + * to keep plain v1 DNS behavior with no snapshotting. + */ + changeDetection?: DnsChangeDetection +} + /** * Configuration for DNS requests. * Defines the query parameters and validation rules. @@ -63,6 +127,12 @@ export interface DnsRequest { */ protocol?: DnsProtocol + /** + * Advanced resolver configuration (query timeout, CNAME chasing). Optional — + * omit to use the backend defaults. + */ + dnsConfig?: DnsConfig + /** * Assertions to validate the DNS response. */ diff --git a/packages/cli/src/constructs/internal/assertion-codegen.ts b/packages/cli/src/constructs/internal/assertion-codegen.ts index 0d6170334..2aed5d6f7 100644 --- a/packages/cli/src/constructs/internal/assertion-codegen.ts +++ b/packages/cli/src/constructs/internal/assertion-codegen.ts @@ -147,6 +147,18 @@ export function valueForGeneralAssertion ( builder.string(assertion.target) }) break + case 'MATCHES': + builder.member(ident('matches')) + builder.call(builder => { + builder.string(assertion.target) + }) + break + case 'NOT_MATCHES': + builder.member(ident('notMatches')) + builder.call(builder => { + builder.string(assertion.target) + }) + break case 'IS_NULL': builder.member(ident('isNull')) builder.call(builder => { diff --git a/packages/cli/src/constructs/internal/assertion.ts b/packages/cli/src/constructs/internal/assertion.ts index 26d1c8a30..e87f7b829 100644 --- a/packages/cli/src/constructs/internal/assertion.ts +++ b/packages/cli/src/constructs/internal/assertion.ts @@ -13,6 +13,8 @@ type Comparison = | 'NOT_CONTAINS' | 'IS_NULL' | 'NOT_NULL' + | 'MATCHES' + | 'NOT_MATCHES' export interface Assertion { source: Source @@ -20,15 +22,23 @@ export interface Assertion { comparison: string target: string regex: string | null + /** + * Quantifier for assertions that evaluate over a set of values (e.g. DNS + * `ANSWER` records). Only emitted when set, so assertions that do not use a + * quantifier keep their existing shape. + */ + quantifier?: string } export class NumericAssertionBuilder { source: Source property?: Property + quantifier?: string - constructor (source: Source, property?: Property) { + constructor (source: Source, property?: Property, quantifier?: string) { this.source = source this.property = property + this.quantifier = quantifier } equals (target: number): Assertion { @@ -55,6 +65,7 @@ export class NumericAssertionBuilder { source: Source property?: string regex?: string + quantifier?: string - constructor (source: Source, property?: string, regex?: string) { + constructor (source: Source, property?: string, regex?: string, quantifier?: string) { this.source = source this.property = property this.regex = regex + this.quantifier = quantifier } equals (target: string | number | boolean): Assertion { @@ -126,6 +139,26 @@ export class GeneralAssertionBuilder { return this._toAssertion('NOT_NULL') } + /** + * Asserts that the value matches the given regular expression. + * + * The pattern is evaluated by the runner using Go's RE2 engine, so + * backreferences and lookaround are not supported. + */ + matches (target: string): Assertion { + return this._toAssertion('MATCHES', target) + } + + /** + * Asserts that the value does NOT match the given regular expression. + * + * The pattern is evaluated by the runner using Go's RE2 engine, so + * backreferences and lookaround are not supported. + */ + notMatches (target: string): Assertion { + return this._toAssertion('NOT_MATCHES', target) + } + /** @private */ private _toAssertion (comparison: Comparison, target?: string | number | boolean): Assertion { return { @@ -134,6 +167,7 @@ export class GeneralAssertionBuilder { property: this.property ?? '', target: target?.toString() ?? '', regex: this.regex ?? null, + ...(this.quantifier !== undefined ? { quantifier: this.quantifier } : {}), } } }