Skip to content

Commit

Permalink
chore: add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbbreuer committed Jan 17, 2025
1 parent c6eefa5 commit ea84959
Show file tree
Hide file tree
Showing 3 changed files with 301 additions and 21 deletions.
91 changes: 77 additions & 14 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,65 @@ export class DnsClient {
private static readonly RESOLV_CONF_PATH = '/etc/resolv.conf'
private static readonly WINDOWS_DNS_KEY = 'SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Nameserver'

private validateDomainName(domain: string): void {
// Check for consecutive dots
if (domain.includes('..')) {
throw new Error(`Invalid domain name: ${domain} (consecutive dots)`)
}

// Check start/end dots
if (domain.startsWith('.') || domain.endsWith('.')) {
throw new Error(`Invalid domain name: ${domain} (starts or ends with dot)`)
}

// Check length
if (domain.length > 253) {
throw new Error(`Domain name too long: ${domain}`)
}

// Check label lengths
const labels = domain.split('.')
for (const label of labels) {
if (label.length > 63) {
throw new Error(`Label too long in domain: ${domain}`)
}
// Check label characters
if (!/^[a-z0-9-]+$/i.test(label)) {
throw new Error(`Invalid characters in domain label: ${label}`)
}
}
}

private validateRecordType(type: string | number): void {
if (typeof type === 'string') {
// Check if the type exists in RecordType enum
const upperType = type.toUpperCase()
if (!(upperType in RecordType)) {
throw new Error(`Invalid record type: ${type}`)
}
}
else if (typeof type === 'number') {
// Check if the number is a valid enum value
const values = Object.values(RecordType).filter(v => typeof v === 'number')
if (!values.includes(type)) {
throw new Error(`Invalid record type number: ${type}`)
}
}
else {
throw new TypeError('Record type must be string or number')
}
}

constructor(options: DnsOptions) {
// The issue is that we're trying to set a TransportType enum value
// to a TransportConfig object. Let's fix the initialization:
this.options = {
transport: {
type: TransportType.UDP,
},
...options,
}

// Validate options
this.validateOptions()
}

async query(): Promise<DnsResponse[]> {
Expand Down Expand Up @@ -150,22 +200,21 @@ export class DnsClient {
}

private resolveTypes(): RecordType[] {
if (!this.options.type)
if (!this.options.type) {
return [RecordType.A]
}

const types = Array.isArray(this.options.type)
? this.options.type
: [this.options.type]

return types.map((type) => {
if (typeof type === 'number')
this.validateRecordType(type)
if (typeof type === 'number') {
return type
const upperType = type.toUpperCase()
const recordType = RecordType[upperType as keyof typeof RecordType]
if (recordType === undefined) {
throw new Error(`Invalid record type: ${type}`)
}
return recordType
const upperType = type.toUpperCase()
return RecordType[upperType as keyof typeof RecordType]
})
}

Expand Down Expand Up @@ -263,12 +312,26 @@ export class DnsClient {
return TransportType.UDP
}

private validateOptions() {
if (!this.options.domains?.length) {
throw new Error('No domains specified')
private validateOptions(): void {
// Validate domains
if (this.options.domains) {
for (const domain of this.options.domains) {
this.validateDomainName(domain)
}
}

// Validate record type(s)
if (this.options.type) {
const types = Array.isArray(this.options.type)
? this.options.type
: [this.options.type]

for (const type of types) {
this.validateRecordType(type)
}
}

// Validate transport options
// Transport validation
const transportCount = [
this.options.udp,
this.options.tcp,
Expand All @@ -280,7 +343,7 @@ export class DnsClient {
throw new Error('Only one transport type can be specified')
}

// Validate HTTPS requirements
// Validate HTTPS transport
if (this.options.https && !this.options.nameserver?.startsWith('https://')) {
throw new Error('HTTPS transport requires an HTTPS nameserver URL')
}
Expand Down
2 changes: 1 addition & 1 deletion src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class DnsFlags {

static fromBuffer(buffer: Buffer): DnsFlags {
const flags = new DnsFlags()
const rawFlags = buffer.readUInt16BE(2)
const rawFlags = buffer.readUInt16BE(0)

// First byte contains QR, OPCODE, AA, TC, RD
flags.response = (rawFlags & 0x8000) !== 0 // QR bit (bit 15)
Expand Down
229 changes: 223 additions & 6 deletions test/dnsx.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,228 @@
import { beforeAll, describe, expect, it } from 'bun:test'
import { beforeAll, describe, expect, it, mock } from 'bun:test'
import { buildQuery, DnsClient, DnsDecoder, DnsFlags, parseResponse } from '../src'
import { TransportType } from '../src/transport'
import { QClass, RecordType } from '../src/types'
import { Buffer } from 'node:buffer'

describe('dnsx', () => {
beforeAll(() => {
process.env.APP_ENV = 'test'
// Mock DNS responses
const mockResponses = {
A: Buffer.from('0d5e81800001000600000000076578616d706c6503636f6d0000010001c00c0001000100000124000417d70088c00c00010001000001240004600780c6c00c0001000100000124000417d7008ac00c0001000100000124000417c0e454c00c0001000100000124000417c0e450c00c00010001000001240004600780af', 'hex'),
AAAA: Buffer.from('9c848180000100010000000006676f6f676c6503636f6d00001c0001c00c001c0001000000d700102a0014504001081c000000000000200e', 'hex'),
MX: Buffer.from('c1ab81800001000100000000096d6963726f736f667403636f6d00000f0001c00c000f000100000df0002a000a0d6d6963726f736f66742d636f6d046d61696c0a70726f74656374696f6e076f75746c6f6f6bc016', 'hex'),
TXT: Buffer.from('77aa838000010000000000000667697468756203636f6d0000100001', 'hex'),
}

// Mock the transport layer
mock.module('./src/transport', () => ({
createTransport: () => ({
query: async (_nameserver: string, _request: Buffer) => {
// Return mock response based on record type
const type = _request.readUInt16BE(_request.length - 4)
switch (type) {
case RecordType.A:
return mockResponses.A
case RecordType.AAAA:
return mockResponses.AAAA
case RecordType.MX:
return mockResponses.MX
case RecordType.TXT:
return mockResponses.TXT
default:
throw new Error('Unsupported record type')
}
},
}),
TransportType,
}))

describe('DnsClient', () => {
describe('Query Building', () => {
it('should build valid DNS queries', () => {
const query = buildQuery({
name: 'example.com',
type: RecordType.A,
class: QClass.IN,
})

expect(query).toBeInstanceOf(Buffer)
expect(query.length).toBeGreaterThan(12) // DNS header size
expect(query.readUInt16BE(2) & 0x8000).toBe(0) // QR bit should be 0 for queries
})

it('should set recursion desired flag', () => {
const query = buildQuery({
name: 'example.com',
type: RecordType.A,
class: QClass.IN,
})

const flags = query.readUInt16BE(2)
expect(flags & 0x0100).toBe(0x0100) // RD bit should be set
})
})

describe('Response Parsing', () => {
it('should parse A record responses', () => {
const response = parseResponse(mockResponses.A)

expect(response.answers).toHaveLength(6)
expect(response.answers[0].type).toBe(RecordType.A)
expect(typeof response.answers[0].data).toBe('string')
expect(response.answers[0].data).toMatch(/^\d+\.\d+\.\d+\.\d+$/)
})

it('should parse AAAA record responses', () => {
const response = parseResponse(mockResponses.AAAA)

expect(response.answers).toHaveLength(1)
expect(response.answers[0].type).toBe(RecordType.AAAA)
expect(typeof response.answers[0].data).toBe('string')
expect(response.answers[0].data).toMatch(/^[0-9a-f:]+$/)
})

it('should parse MX record responses', () => {
const response = parseResponse(mockResponses.MX)

expect(response.answers).toHaveLength(1)
expect(response.answers[0].type).toBe(RecordType.MX)
expect(response.answers[0].data).toHaveProperty('preference')
expect(response.answers[0].data).toHaveProperty('exchange')
})

it('should handle empty responses', () => {
const response = parseResponse(mockResponses.TXT)

expect(response.answers).toHaveLength(0)
})
})

describe('DNS Flags', () => {
it('should correctly parse response flags', () => {
// Create a buffer with valid DNS flags
const buffer = Buffer.alloc(2)
buffer.writeUInt16BE(0x8180) // Standard response flags
const flags = DnsFlags.fromBuffer(buffer)

expect(flags.response).toBe(true)
expect(flags.recursionDesired).toBe(true)
expect(flags.recursionAvailable).toBe(true)
})

it('should correctly encode flags', () => {
const flags = new DnsFlags()
flags.response = true
flags.recursionDesired = true

const buffer = flags.toBuffer()
expect(buffer.readUInt16BE(0) & 0x8000).toBe(0x8000) // QR bit
expect(buffer.readUInt16BE(0) & 0x0100).toBe(0x0100) // RD bit
})
})

it('should work', async () => {
expect(true).toBe(true)
describe('Integration Tests', () => {
let client: DnsClient

beforeAll(() => {
client = new DnsClient({
domains: ['example.com'],
type: 'A',
nameserver: '1.1.1.1',
})
})

it('should resolve A records', async () => {
const responses = await client.query()

expect(responses).toHaveLength(1)
expect(responses[0].answers).toHaveLength(6)
expect(responses[0].answers[0].type).toBe(RecordType.A)
})

it('should handle multiple record types', async () => {
client = new DnsClient({
domains: ['example.com'],
type: ['A', 'AAAA'],
nameserver: '1.1.1.1',
})

const responses = await client.query()
expect(responses).toHaveLength(2)
})

it('should handle multiple domains', async () => {
client = new DnsClient({
domains: ['example.com', 'google.com'],
type: 'A',
nameserver: '1.1.1.1',
})

const responses = await client.query()
expect(responses).toHaveLength(2)
})

it('should work with different transport types', async () => {
for (const transport of [TransportType.UDP, TransportType.TCP, TransportType.TLS, TransportType.HTTPS]) {
client = new DnsClient({
domains: ['example.com'],
type: 'A',
nameserver: '1.1.1.1',
transport: { type: transport },
})

const responses = await client.query()
expect(responses).toHaveLength(1)
}
})
})

describe('Error Handling', () => {
it('should handle malformed responses', () => {
expect(() => parseResponse(Buffer.from('invalid'))).toThrow()
})

it('should validate domain names', () => {
expect(() => new DnsClient({
domains: ['invalid..com'],
type: 'A',
})).toThrow()
})

it('should handle invalid record types', () => {
expect(() => new DnsClient({
domains: ['example.com'],
type: 'INVALID' as any,
})).toThrow()
})

it('should handle network errors', async () => {
mock.module('./src/transport', () => ({
createTransport: () => ({
query: async () => { throw new Error('Network error') },
}),
TransportType,
}))

const client = new DnsClient({
domains: ['example.com'],
type: 'A',
})

await expect(client.query()).rejects.toThrow()

Check failure on line 210 in test/dnsx.test.ts

View workflow job for this annotation

GitHub Actions / test

error:

Expected promise that rejects Received promise that resolved: Promise { <resolved> } at <anonymous> (/home/runner/work/dnsx/dnsx/test/dnsx.test.ts:210:44) at <anonymous> (/home/runner/work/dnsx/dnsx/test/dnsx.test.ts:197:40)
})
})

describe('DNS Decoder', () => {
it('should handle name compression', () => {
const decoder = new DnsDecoder(mockResponses.MX)
decoder.readHeader() // Skip header

const name = decoder.readName()
expect(name).toBe('microsoft.com')
})

it('should validate message boundaries', () => {
const decoder = new DnsDecoder(Buffer.from([]))
expect(() => decoder.readHeader()).toThrow()
})
})
})

0 comments on commit ea84959

Please sign in to comment.