From 4e07f7c8fa27dfab7f378a1c2c63f910aef9be1e Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Mon, 25 May 2020 06:23:21 +0000 Subject: [PATCH] refactor: allow passing in a custom dns lookup function --- src/connector.ts | 65 ++- src/instance-lookup.ts | 39 +- src/sender.ts | 8 +- test/unit/connector-test.js | 668 ++++++++++++++++-------------- test/unit/instance-lookup-test.js | 329 ++++++--------- test/unit/sender-test.js | 8 +- 6 files changed, 543 insertions(+), 574 deletions(-) diff --git a/src/connector.ts b/src/connector.ts index 4630fddd3..5fcb73401 100644 --- a/src/connector.ts +++ b/src/connector.ts @@ -45,9 +45,11 @@ export class ParallelConnectionStrategy { } for (let i = 0, len = addresses.length; i < len; i++) { - const socket = sockets[i] = net.connect(Object.create(this.options, { - host: { value: addresses[i].address } - })); + const socket = sockets[i] = net.connect({ + ...this.options, + host: addresses[i].address, + family: addresses[i].family + }); socket.on('error', onError); socket.on('connect', onConnect); @@ -70,9 +72,11 @@ export class SequentialConnectionStrategy { return callback(new Error('Could not connect (sequence)')); } - const socket = net.connect(Object.create(this.options, { - host: { value: next.address } - })); + const socket = net.connect({ + ...this.options, + host: next.address, + family: next.family + }); const onError = (_err: Error) => { socket.removeListener('error', onError); @@ -95,48 +99,21 @@ export class SequentialConnectionStrategy { } } +type LookupFunction = (hostname: string, options: dns.LookupAllOptions, callback: (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void) => void; + export class Connector { options: { port: number, host: string, localAddress?: string }; multiSubnetFailover: boolean; + lookup: LookupFunction; - constructor(options: { port: number, host: string, localAddress?: string }, multiSubnetFailover: boolean) { + constructor(options: { port: number, host: string, localAddress?: string, lookup?: LookupFunction }, multiSubnetFailover: boolean) { this.options = options; + this.lookup = options.lookup ?? dns.lookup; this.multiSubnetFailover = multiSubnetFailover; } execute(cb: (err: Error | null, socket?: net.Socket) => void) { - if (net.isIP(this.options.host)) { - this.executeForIP(cb); - } else { - this.executeForHostname(cb); - } - } - - executeForIP(cb: (err: Error | null, socket?: net.Socket) => void) { - const socket = net.connect(this.options); - - const onError = (err: Error) => { - socket.removeListener('error', onError); - socket.removeListener('connect', onConnect); - - socket.destroy(); - - cb(err); - }; - - const onConnect = () => { - socket.removeListener('error', onError); - socket.removeListener('connect', onConnect); - - cb(null, socket); - }; - - socket.on('error', onError); - socket.on('connect', onConnect); - } - - executeForHostname(cb: (err: Error | null, socket?: net.Socket) => void) { - dns.lookup(punycode.toASCII(this.options.host), { all: true }, (err, addresses) => { + this.lookupAllAddresses(this.options.host, (err, addresses) => { if (err) { return cb(err); } @@ -148,4 +125,14 @@ export class Connector { } }); } + + lookupAllAddresses(host: string, callback: (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void) { + if (net.isIPv6(host)) { + process.nextTick(callback, null, [{ address: host, family: 6 }]); + } else if (net.isIPv4(host)) { + process.nextTick(callback, null, [{ address: host, family: 4 }]); + } else { + this.lookup.call(null, punycode.toASCII(host), { all: true }, callback); + } + } } diff --git a/src/instance-lookup.ts b/src/instance-lookup.ts index c5f60ae17..37c96528c 100644 --- a/src/instance-lookup.ts +++ b/src/instance-lookup.ts @@ -1,4 +1,5 @@ import { Sender } from './sender'; +import dns from 'dns'; const SQL_SERVER_BROWSER_PORT = 1434; const TIMEOUT = 2 * 1000; @@ -6,14 +7,16 @@ const RETRIES = 3; // There are three bytes at the start of the response, whose purpose is unknown. const MYSTERY_HEADER_LENGTH = 3; +type LookupFunction = (hostname: string, options: dns.LookupAllOptions, callback: (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void) => void; + // Most of the functionality has been determined from from jTDS's MSSqlServerInfo class. export class InstanceLookup { // Wrapper allows for stubbing Sender when unit testing instance-lookup. - createSender(host: string, port: number, request: Buffer) { - return new Sender(host, port, request); + createSender(host: string, port: number, lookup: LookupFunction, request: Buffer) { + return new Sender(host, port, lookup, request); } - instanceLookup(options: { server: string, instanceName: string, timeout?: number, retries?: number }, callback: (message: string | undefined, port?: number) => void) { + instanceLookup(options: { server: string, instanceName: string, timeout?: number, retries?: number, port?: number, lookup?: LookupFunction }, callback: (message: string | undefined, port?: number) => void) { const server = options.server; if (typeof server !== 'string') { throw new TypeError('Invalid arguments: "server" must be a string'); @@ -34,25 +37,37 @@ export class InstanceLookup { throw new TypeError('Invalid arguments: "retries" must be a number'); } + if (options.lookup !== undefined && typeof options.lookup !== 'function') { + throw new TypeError('Invalid arguments: "lookup" must be a function'); + } + const lookup = options.lookup ?? dns.lookup; + + if (options.port !== undefined && typeof options.port !== 'number') { + throw new TypeError('Invalid arguments: "port" must be a number'); + } + const port = options.port ?? SQL_SERVER_BROWSER_PORT; + if (typeof callback !== 'function') { throw new TypeError('Invalid arguments: "callback" must be a function'); } - let sender: Sender; - let timer: NodeJS.Timeout; let retriesLeft = retries; - const onTimeout = () => { - sender.cancel(); - makeAttempt(); - }; - const makeAttempt = () => { + let sender: Sender; + let timer: NodeJS.Timeout; + + const onTimeout = () => { + sender.cancel(); + makeAttempt(); + }; + if (retriesLeft > 0) { retriesLeft--; const request = Buffer.from([0x02]); - sender = this.createSender(options.server, SQL_SERVER_BROWSER_PORT, request); + sender = this.createSender(options.server, port, lookup, request); + timer = setTimeout(onTimeout, timeout); sender.execute((err, response) => { clearTimeout(timer); if (err) { @@ -69,8 +84,6 @@ export class InstanceLookup { } } }); - - timer = setTimeout(onTimeout, timeout); } else { callback('Failed to get response from SQL Server Browser on ' + server); } diff --git a/src/sender.ts b/src/sender.ts index 265986ccd..1fa3f1a5e 100644 --- a/src/sender.ts +++ b/src/sender.ts @@ -3,6 +3,8 @@ import dns from 'dns'; import net from 'net'; import * as punycode from 'punycode'; +type LookupFunction = (hostname: string, options: dns.LookupAllOptions, callback: (err: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void) => void; + export class ParallelSendStrategy { addresses: dns.LookupAddress[]; port: number; @@ -106,11 +108,13 @@ export class Sender { port: number; request: Buffer; parallelSendStrategy: ParallelSendStrategy | null; + lookup: LookupFunction; - constructor(host: string, port: number, request: Buffer) { + constructor(host: string, port: number, lookup: LookupFunction, request: Buffer) { this.host = host; this.port = port; this.request = request; + this.lookup = lookup; this.parallelSendStrategy = null; } @@ -129,7 +133,7 @@ export class Sender { // Wrapper for stubbing. Sinon does not have support for stubbing module functions. invokeLookupAll(host: string, cb: (error: Error | null, addresses?: dns.LookupAddress[]) => void) { - dns.lookup(punycode.toASCII(host), { all: true }, cb); + this.lookup.call(null, punycode.toASCII(host), { all: true }, cb); } executeForHostname(cb: (error: Error | null, message?: Buffer) => void) { diff --git a/test/unit/connector-test.js b/test/unit/connector-test.js index b27f36d39..585a6fea5 100644 --- a/test/unit/connector-test.js +++ b/test/unit/connector-test.js @@ -1,44 +1,16 @@ const Mitm = require('mitm'); const sinon = require('sinon'); -const dns = require('dns'); const punycode = require('punycode'); const assert = require('chai').assert; -const ParallelConnectionStrategy = require('../../src/connector') - .ParallelConnectionStrategy; -const SequentialConnectionStrategy = require('../../src/connector') - .SequentialConnectionStrategy; -const Connector = require('../../src/connector').Connector; +const { + ParallelConnectionStrategy, + SequentialConnectionStrategy, + Connector +} = require('../../src/connector'); -function connectToIpTestImpl(hostIp, localIp, mitm, done) { - const connectionOptions = { - host: hostIp, - port: 12345, - localAddress: localIp - }; - const connector = new Connector(connectionOptions, true); - - let expectedSocket; - - mitm.once('connect', function(socket, options) { - expectedSocket = socket; - - assert.strictEqual(options, connectionOptions); - }); - - connector.execute(function(err, socket) { - if (err) { - return done(err); - } - - assert.strictEqual(socket, expectedSocket); - - done(); - }); -} - -describe('connector tests', function() { - describe('Connector with MultiSubnetFailover', function() { +describe('Connector', function() { + describe('with MultiSubnetFailover', function() { let mitm; beforeEach(function() { @@ -50,15 +22,77 @@ describe('connector tests', function() { mitm.disable(); }); - it('should connects directly if given an IP v4 address', function(done) { - connectToIpTestImpl('127.0.0.1', '192.168.0.1', mitm, done); + it('connects directly if given an IP v4 address', function(done) { + const hostIp = '127.0.0.1'; + const localIp = '192.168.0.1'; + + const connectionOptions = { + host: hostIp, + port: 12345, + localAddress: localIp + }; + const connector = new Connector(connectionOptions, true); + + let expectedSocket; + + mitm.once('connect', function(socket, options) { + expectedSocket = socket; + + assert.deepEqual(options, { + host: hostIp, + port: 12345, + localAddress: localIp, + family: 4 + }); + }); + + connector.execute(function(err, socket) { + if (err) { + return done(err); + } + + assert.strictEqual(socket, expectedSocket); + + done(); + }); }); - it('should connects directly if given an IP v6 address', function(done) { - connectToIpTestImpl('::1', '2002:20:0:0:0:0:1:2', mitm, done); + it('connects directly if given an IP v6 address', function(done) { + const hostIp = '::1'; + const localIp = '2002:20:0:0:0:0:1:2'; + + const connectionOptions = { + host: hostIp, + port: 12345, + localAddress: localIp + }; + const connector = new Connector(connectionOptions, true); + + let expectedSocket; + + mitm.once('connect', function(socket, options) { + expectedSocket = socket; + + assert.deepEqual(options, { + host: hostIp, + port: 12345, + localAddress: localIp, + family: 6 + }); + }); + + connector.execute(function(err, socket) { + if (err) { + return done(err); + } + + assert.strictEqual(socket, expectedSocket); + + done(); + }); }); - it('should uses a parallel connection strategy', function(done) { + it('uses a parallel connection strategy', function(done) { const connector = new Connector({ host: 'localhost', port: 12345 }, true); const spy = sinon.spy(ParallelConnectionStrategy.prototype, 'connect'); @@ -75,7 +109,7 @@ describe('connector tests', function() { }); }); - describe('Connector without MultiSubnetFailover', function() { + describe('without MultiSubnetFailover', function() { let mitm; beforeEach(function() { @@ -91,7 +125,7 @@ describe('connector tests', function() { sinon.restore(); }); - it('should connect directly if given an IP address', function(done) { + it('connects directly if given an IP address', function(done) { const connectionOptions = { host: '127.0.0.1', port: 12345, @@ -103,7 +137,12 @@ describe('connector tests', function() { mitm.once('connect', function(socket, options) { expectedSocket = socket; - assert.strictEqual(options, connectionOptions); + assert.deepEqual(options, { + host: '127.0.0.1', + port: 12345, + localAddress: '192.168.0.1', + family: 4 + }); }); connector.execute(function(err, socket) { @@ -117,7 +156,7 @@ describe('connector tests', function() { }); }); - it('should uses a sequential connection strategy', function(done) { + it('uses a sequential connection strategy', function(done) { const connector = new Connector({ host: 'localhost', port: 12345 }, false); const spy = sinon.spy( @@ -137,357 +176,358 @@ describe('connector tests', function() { }); }); - describe('SequentialConnectionStrategy', function() { - let mitm; - - beforeEach(function() { - mitm = new Mitm(); - mitm.enable(); - }); - - afterEach(function() { - mitm.disable(); - }); - - it('should tries to connect to all addresses in sequence', function(done) { - const strategy = new SequentialConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); - - const attemptedConnections = []; - mitm.on('connect', function(socket, options) { - attemptedConnections.push(options); - - const expectedConnectionCount = attemptedConnections.length; - const handler = () => { - socket.removeListener('connect', handler); - socket.removeListener('error', handler); - - assert.strictEqual(attemptedConnections.length, expectedConnectionCount); - }; - - socket.on('connect', handler); - socket.on('error', handler); - - if (options.host !== '127.0.0.4') { - process.nextTick(() => { - socket.emit('error', new Error()); - }); - } + describe('Test unicode SQL Server name', function() { + it('test IDN Server name', function(done) { + const lookup = sinon.spy(function lookup(hostname, options, callback) { + callback([{ address: '127.0.0.1', family: 4 }]); }); - strategy.connect(function(err) { - if (err) { - return done(err); - } - - assert.strictEqual(attemptedConnections.length, 3); - - assert.strictEqual(attemptedConnections[0].host, '127.0.0.2'); - assert.strictEqual(attemptedConnections[0].port, 12345); - assert.strictEqual(attemptedConnections[0].localAddress, '192.168.0.1'); - - assert.strictEqual(attemptedConnections[1].host, '2002:20:0:0:0:0:1:3'); - assert.strictEqual(attemptedConnections[1].port, 12345); - assert.strictEqual(attemptedConnections[1].localAddress, '192.168.0.1'); + const server = '本地主机.ad'; + const connector = new Connector({ host: server, port: 12345, lookup: lookup }, true); - assert.strictEqual(attemptedConnections[2].host, '127.0.0.4'); - assert.strictEqual(attemptedConnections[2].port, 12345); - assert.strictEqual(attemptedConnections[2].localAddress, '192.168.0.1'); + connector.execute(() => { + assert.isOk(lookup.called, 'Failed to call `lookup` function for hostname'); + assert.isOk(lookup.calledWithMatch(punycode.toASCII(server)), 'Unexpected hostname passed to `lookup`'); done(); }); }); - it('should passes the first succesfully connected socket to the callback', function(done) { - const strategy = new SequentialConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); - - let expectedSocket; - mitm.on('connect', function(socket, opts) { - if (opts.host !== '127.0.0.4') { - socket.destroy(new Error()); - } else { - expectedSocket = socket; - } + it('test ASCII Server name', function(done) { + const lookup = sinon.spy(function lookup(hostname, options, callback) { + callback([{ address: '127.0.0.1', family: 4 }]); }); - strategy.connect(function(err, socket) { - assert.strictEqual(expectedSocket, socket); + const server = 'localhost'; + const connector = new Connector({ host: server, port: 12345, lookup: lookup }, true); + + connector.execute(() => { + assert.isOk(lookup.called, 'Failed to call `lookup` function for hostname'); + assert.isOk(lookup.calledWithMatch(server), 'Unexpected hostname passed to `lookup`'); done(); }); }); + }); +}); - it('should only attempts new connections until the first successful connection', function(done) { - const strategy = new SequentialConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); - - const attemptedConnections = []; - - mitm.on('connect', function(socket, options) { - attemptedConnections.push(options); - }); - - strategy.connect(function(err) { - if (err) { - return done(err); - } +describe('SequentialConnectionStrategy', function() { + let mitm; - assert.strictEqual(attemptedConnections.length, 1); + beforeEach(function() { + mitm = new Mitm(); + mitm.enable(); + }); - assert.strictEqual(attemptedConnections[0].host, '127.0.0.2'); - assert.strictEqual(attemptedConnections[0].port, 12345); - assert.strictEqual(attemptedConnections[0].localAddress, '192.168.0.1'); + afterEach(function() { + mitm.disable(); + }); - done(); - }); - }); + it('tries to connect to all addresses in sequence', function(done) { + const strategy = new SequentialConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); + + const attemptedConnections = []; + mitm.on('connect', function(socket, options) { + attemptedConnections.push(options); + + const expectedConnectionCount = attemptedConnections.length; + const handler = () => { + socket.removeListener('connect', handler); + socket.removeListener('error', handler); + + assert.strictEqual(attemptedConnections.length, expectedConnectionCount); + }; - it('should fails if all sequential connections fail', function(done) { - const strategy = new SequentialConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); + socket.on('connect', handler); + socket.on('error', handler); - mitm.on('connect', function(socket) { + if (options.host !== '127.0.0.4') { process.nextTick(() => { socket.emit('error', new Error()); }); - }); + } + }); - strategy.connect(function(err, socket) { - assert.equal('Could not connect (sequence)', err.message); + strategy.connect(function(err) { + if (err) { + return done(err); + } - done(); - }); - }); + assert.strictEqual(attemptedConnections.length, 3); - it('should destroys all sockets except for the first succesfully connected socket', function(done) { - const strategy = new SequentialConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); + assert.strictEqual(attemptedConnections[0].host, '127.0.0.2'); + assert.strictEqual(attemptedConnections[0].port, 12345); + assert.strictEqual(attemptedConnections[0].localAddress, '192.168.0.1'); - const attemptedSockets = []; + assert.strictEqual(attemptedConnections[1].host, '2002:20:0:0:0:0:1:3'); + assert.strictEqual(attemptedConnections[1].port, 12345); + assert.strictEqual(attemptedConnections[1].localAddress, '192.168.0.1'); - mitm.on('connect', function(socket, options) { - attemptedSockets.push(socket); + assert.strictEqual(attemptedConnections[2].host, '127.0.0.4'); + assert.strictEqual(attemptedConnections[2].port, 12345); + assert.strictEqual(attemptedConnections[2].localAddress, '192.168.0.1'); - if (options.host !== '127.0.0.4') { - process.nextTick(() => { - socket.emit('error', new Error()); - }); - } - }); + done(); + }); + }); - strategy.connect(function(err, socket) { - if (err) { - return done(err); - } + it('passes the first succesfully connected socket to the callback', function(done) { + const strategy = new SequentialConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); + + let expectedSocket; + mitm.on('connect', function(socket, opts) { + if (opts.host !== '127.0.0.4') { + socket.destroy(new Error()); + } else { + expectedSocket = socket; + } + }); - assert.isOk(attemptedSockets[0].destroyed); - assert.isOk(attemptedSockets[1].destroyed); - assert.isOk(!attemptedSockets[2].destroyed); + strategy.connect(function(err, socket) { + assert.strictEqual(expectedSocket, socket); - done(); - }); + done(); }); }); - describe('ParallelConnectionStrategy', function() { - let mitm; + it('only attempts new connections until the first successful connection', function(done) { + const strategy = new SequentialConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); - beforeEach(function() { - mitm = new Mitm(); - mitm.enable(); - }); + const attemptedConnections = []; - afterEach(function() { - mitm.disable(); + mitm.on('connect', function(socket, options) { + attemptedConnections.push(options); }); - it('should tries to connect to all addresses in parallel', function(done) { - const strategy = new ParallelConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); + strategy.connect(function(err) { + if (err) { + return done(err); + } - const attemptedConnections = []; + assert.strictEqual(attemptedConnections.length, 1); - mitm.on('connect', function(socket, options) { - attemptedConnections.push(options); + assert.strictEqual(attemptedConnections[0].host, '127.0.0.2'); + assert.strictEqual(attemptedConnections[0].port, 12345); + assert.strictEqual(attemptedConnections[0].localAddress, '192.168.0.1'); - socket.once('connect', function() { - assert.strictEqual(attemptedConnections.length, 3); - }); + done(); + }); + }); + + it('fails if all sequential connections fail', function(done) { + const strategy = new SequentialConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); + + mitm.on('connect', function(socket) { + process.nextTick(() => { + socket.emit('error', new Error()); }); + }); - strategy.connect(function(err, socket) { - if (err) { - return done(err); - } + strategy.connect(function(err, socket) { + assert.equal('Could not connect (sequence)', err.message); - assert.strictEqual(attemptedConnections[0].host, '127.0.0.2'); - assert.strictEqual(attemptedConnections[0].port, 12345); - assert.strictEqual(attemptedConnections[0].localAddress, '192.168.0.1'); + done(); + }); + }); - assert.strictEqual(attemptedConnections[1].host, '2002:20:0:0:0:0:1:3'); - assert.strictEqual(attemptedConnections[1].port, 12345); - assert.strictEqual(attemptedConnections[1].localAddress, '192.168.0.1'); + it('destroys all sockets except for the first succesfully connected socket', function(done) { + const strategy = new SequentialConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); - assert.strictEqual(attemptedConnections[2].host, '127.0.0.4'); - assert.strictEqual(attemptedConnections[2].port, 12345); - assert.strictEqual(attemptedConnections[2].localAddress, '192.168.0.1'); + const attemptedSockets = []; - done(); - }); - }); + mitm.on('connect', function(socket, options) { + attemptedSockets.push(socket); - it('should fails if all parallel connections fail', function(done) { - const strategy = new ParallelConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); - - mitm.on('connect', function(socket) { + if (options.host !== '127.0.0.4') { process.nextTick(() => { socket.emit('error', new Error()); }); - }); + } + }); - strategy.connect(function(err, socket) { - assert.equal('Could not connect (parallel)', err.message); + strategy.connect(function(err, socket) { + if (err) { + return done(err); + } - done(); - }); + assert.isOk(attemptedSockets[0].destroyed); + assert.isOk(attemptedSockets[1].destroyed); + assert.isOk(!attemptedSockets[2].destroyed); + + done(); }); + }); +}); - it('should passes the first succesfully connected socket to the callback', function(done) { - const strategy = new ParallelConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); +describe('ParallelConnectionStrategy', function() { + let mitm; - let expectedSocket; - mitm.on('connect', function(socket, opts) { - if (opts.host !== '127.0.0.4') { - process.nextTick(() => { - socket.emit('error', new Error()); - }); - } else { - expectedSocket = socket; - } - }); + beforeEach(function() { + mitm = new Mitm(); + mitm.enable(); + }); - strategy.connect(function(err, socket) { - if (err) { - return done(err); - } + afterEach(function() { + mitm.disable(); + }); - assert.strictEqual(expectedSocket, socket); + it('tries to connect to all addresses in parallel', function(done) { + const strategy = new ParallelConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); - done(); + const attemptedConnections = []; + + mitm.on('connect', function(socket, options) { + attemptedConnections.push(options); + + socket.once('connect', function() { + assert.strictEqual(attemptedConnections.length, 3); }); }); - it('should destroys all sockets except for the first succesfully connected socket', function(done) { - const strategy = new ParallelConnectionStrategy( - [ - { address: '127.0.0.2' }, - { address: '2002:20:0:0:0:0:1:3' }, - { address: '127.0.0.4' } - ], - { port: 12345, localAddress: '192.168.0.1' } - ); + strategy.connect(function(err, socket) { + if (err) { + return done(err); + } - const attemptedSockets = []; + assert.strictEqual(attemptedConnections[0].host, '127.0.0.2'); + assert.strictEqual(attemptedConnections[0].port, 12345); + assert.strictEqual(attemptedConnections[0].localAddress, '192.168.0.1'); - mitm.on('connect', function(socket) { - attemptedSockets.push(socket); - }); + assert.strictEqual(attemptedConnections[1].host, '2002:20:0:0:0:0:1:3'); + assert.strictEqual(attemptedConnections[1].port, 12345); + assert.strictEqual(attemptedConnections[1].localAddress, '192.168.0.1'); - strategy.connect(function(err, socket) { - if (err) { - return done(err); - } + assert.strictEqual(attemptedConnections[2].host, '127.0.0.4'); + assert.strictEqual(attemptedConnections[2].port, 12345); + assert.strictEqual(attemptedConnections[2].localAddress, '192.168.0.1'); - assert.isOk(!attemptedSockets[0].destroyed); - assert.isOk(attemptedSockets[1].destroyed); - assert.isOk(attemptedSockets[2].destroyed); + done(); + }); + }); - done(); + it('fails if all parallel connections fail', function(done) { + const strategy = new ParallelConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); + + mitm.on('connect', function(socket) { + process.nextTick(() => { + socket.emit('error', new Error()); }); }); - }); - describe('Test unicode SQL Server name', function() { - let spy; + strategy.connect(function(err, socket) { + assert.equal('Could not connect (parallel)', err.message); - beforeEach(function() { - // Spy the dns.lookup so we can verify if it receives punycode value for IDN Server names - spy = sinon.spy(dns, 'lookup'); + done(); }); + }); - afterEach(function() { - sinon.restore(); + it('passes the first succesfully connected socket to the callback', function(done) { + const strategy = new ParallelConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); + + let expectedSocket; + mitm.on('connect', function(socket, opts) { + if (opts.host !== '127.0.0.4') { + process.nextTick(() => { + socket.emit('error', new Error()); + }); + } else { + expectedSocket = socket; + } }); - it('should test IDN Server name', function() { - const server = '本地主机.ad'; - const connector = new Connector({ host: server, port: 12345 }, true); + strategy.connect(function(err, socket) { + if (err) { + return done(err); + } - connector.execute(() => { }); + assert.strictEqual(expectedSocket, socket); - assert.isOk(spy.called, 'Failed to call dns.lookup on hostname'); - assert.isOk(spy.calledWithMatch(punycode.toASCII(server)), 'Unexpcted hostname passed to dns.lookup'); + done(); }); + }); - it('should test ASCII Server name', function() { - const server = 'localhost'; - const connector = new Connector({ host: server, port: 12345 }, true); + it('destroys all sockets except for the first succesfully connected socket', function(done) { + const strategy = new ParallelConnectionStrategy( + [ + { address: '127.0.0.2' }, + { address: '2002:20:0:0:0:0:1:3' }, + { address: '127.0.0.4' } + ], + { port: 12345, localAddress: '192.168.0.1' } + ); - connector.execute(() => { }); + const attemptedSockets = []; - assert.isOk(spy.called, 'Failed to call dns.lookup on hostname'); - assert.isOk(spy.calledWithMatch(server), 'Unexpcted hostname passed to dns.lookup'); + mitm.on('connect', function(socket) { + attemptedSockets.push(socket); + }); + + strategy.connect(function(err, socket) { + if (err) { + return done(err); + } + + assert.isOk(!attemptedSockets[0].destroyed); + assert.isOk(attemptedSockets[1].destroyed); + assert.isOk(attemptedSockets[2].destroyed); + + done(); }); }); -}); +}); \ No newline at end of file diff --git a/test/unit/instance-lookup-test.js b/test/unit/instance-lookup-test.js index ba2e97684..ad1ef1679 100644 --- a/test/unit/instance-lookup-test.js +++ b/test/unit/instance-lookup-test.js @@ -1,8 +1,8 @@ const InstanceLookup = require('../../src/instance-lookup').InstanceLookup; const sinon = require('sinon'); -const dns = require('dns'); const punycode = require('punycode'); const assert = require('chai').assert; +const dgram = require('dgram'); describe('instanceLookup invalid args', function() { let instanceLookup; @@ -56,218 +56,139 @@ describe('instanceLookup invalid args', function() { }); }); -describe('instanceLookup functional unit tests', function() { +describe('InstanceLookup', function() { + let server; - let options; - let anyPort; - let anyRequest; - let anyMessage; - let anyError; - let anySqlPort; - let instanceLookup; - let testSender; - let createSenderStub; - let senderExecuteStub; - let parseStub; - - - beforeEach(function() { - options = { - server: 'server', - instanceName: 'instance', - timeout: 1000, - retries: 3 - }; - - anyPort = 1234; - anyRequest = Buffer.alloc(0x02); - anyMessage = 'any message'; - anyError = new Error('any error'); - anySqlPort = 2345; - - instanceLookup = new InstanceLookup(); - - // Stub out createSender method to return the Sender we create. This allows us - // to override the execute method on Sender so we can test instance lookup code - // without triggering network activity. - testSender = instanceLookup.createSender( - options.server, - anyPort, - anyRequest - ); - createSenderStub = sinon.stub( - instanceLookup, - 'createSender' - ); - createSenderStub.returns(testSender); - senderExecuteStub = sinon.stub(testSender, 'execute'); - - // Stub parseBrowserResponse so we can mimic success and failure without creating - // elaborate responses. parseBrowserResponse itself has unit tests to ensure that - // it functions correctly. - parseStub = sinon.stub( - instanceLookup, - 'parseBrowserResponse' - ); + beforeEach(function(done) { + server = dgram.createSocket('udp4'); + server.bind(0, '127.0.0.1', done); }); - afterEach(function() { - sinon.restore(); - }), - - it('success', (done) => { - senderExecuteStub.callsArgWithAsync(0, null, anyMessage); - parseStub - .withArgs(anyMessage, options.instanceName) - .returns(anySqlPort); - - instanceLookup.instanceLookup(options, (error, port) => { - assert.strictEqual(error, undefined); - assert.strictEqual(port, anySqlPort); - - assert.ok(createSenderStub.calledOnce); - assert.strictEqual(createSenderStub.args[0][0], options.server); - assert.strictEqual(createSenderStub.args[0][3]); + afterEach(function(done) { + server.close(done); + }); - assert.ok(senderExecuteStub.calledOnce); - assert.ok(parseStub.calledOnce); + it('sends a request to the given server browser endpoint', function(done) { + server.on('message', (msg) => { + assert.deepEqual(msg, Buffer.from([0x02])); done(); }); - }), - - it('sender fail', (done) => { - senderExecuteStub.callsArgWithAsync(0, anyError, undefined); - - instanceLookup.instanceLookup(options, (error, port) => { - assert.ok(error.indexOf(anyError.message) !== -1); - assert.strictEqual(port, undefined); - assert.ok(createSenderStub.calledOnce); - assert.strictEqual(createSenderStub.args[0][0], options.server); - assert.strictEqual(createSenderStub.args[0][3]); - - assert.ok(senderExecuteStub.calledOnce); - assert.strictEqual(parseStub.callCount, 0); - - done(); + new InstanceLookup().instanceLookup({ + server: server.address().address, + port: server.address().port, + instanceName: 'second', + timeout: 500, + retries: 1, + }, () => { + // Ignore }); - }), - - it('parse fail', (done) => { - senderExecuteStub.callsArgWithAsync(0, null, anyMessage); - parseStub - .withArgs(anyMessage, options.instanceName) - .returns(null); + }); - instanceLookup.instanceLookup(options, (error, port) => { - assert.ok(error.indexOf('not found') !== -1); - assert.strictEqual(port, undefined); + describe('when not receiving a response', function(done) { + it('times out after the given timeout period', function(done) { + let timedOut = false; + let errored = false; - assert.ok(createSenderStub.calledOnce); - assert.strictEqual(createSenderStub.args[0][0], options.server); - assert.strictEqual(createSenderStub.args[0][3]); + setTimeout(() => { + timedOut = true; + }, 500); - assert.ok(senderExecuteStub.calledOnce); - assert.ok(parseStub.calledOnce); + setTimeout(() => { + assert.isTrue(errored); + done(); + }, 600); - done(); + new InstanceLookup().instanceLookup({ + server: server.address().address, + port: server.address().port, + instanceName: 'instance', + timeout: 500, + retries: 1, + }, (err) => { + assert.isOk(err); + assert.match(err, /^Failed to get response from SQL Server Browser/); + assert.isTrue(timedOut); + + errored = true; + }); }); - }), - - it('retry success', (done) => { - // First invocation of execute will not invoke callback. This will cause a timeout - // and trigger a retry. Setup to invoke callback on second invocation. - senderExecuteStub - .onCall(1) - .callsArgWithAsync(0, null, anyMessage); - parseStub - .withArgs(anyMessage, options.instanceName) - .returns(anySqlPort); - - const clock = sinon.useFakeTimers(); + }); - instanceLookup.instanceLookup(options, (error, port) => { - assert.strictEqual(error, undefined); - assert.strictEqual(port, anySqlPort); + describe('when receiving a response before timing out', function() { + it('returns the port for the instance with a matching name', function(done) { + server.on('message', (msg, rinfo) => { + const response = [ + 'ServerName;WINDOWS2;InstanceName;first;IsClustered;No;Version;10.50.2500.0;tcp;1444;;', + 'ServerName;WINDOWS2;InstanceName;second;IsClustered;No;Version;10.50.2500.0;tcp;1433;;', + 'ServerName;WINDOWS2;InstanceName;third;IsClustered;No;Version;10.50.2500.0;tcp;1445;;' + ].join(''); - assert.ok(createSenderStub.callCount, 2); - for (let j = 0; j < createSenderStub.callCount; j++) { - assert.strictEqual(createSenderStub.args[j][0], options.server); - assert.strictEqual(createSenderStub.args[j][3]); - } + server.send(response, rinfo.port, rinfo.address); + }); - // Execute called twice but parse only called once as the first call to execute times out. - assert.strictEqual(senderExecuteStub.callCount, 2); - assert.ok(parseStub.calledOnce); + new InstanceLookup().instanceLookup({ + server: server.address().address, + port: server.address().port, + instanceName: 'second', + timeout: 500, + retries: 1, + }, (err, result) => { + assert.ifError(err); - clock.restore(); + assert.strictEqual(result, 1433); - done(); + done(); + }); }); + }); - // Forward clock to trigger timeout. - clock.tick(options.timeout * 1.1); - }), - - it('retry fail', (done) => { - const clock = sinon.useFakeTimers(); - - const forwardClock = () => { - clock.tick(options.timeout * 1.1); - }; - - function scheduleForwardClock() { - // This function is called in place of sender.execute(). We don't want to - // rely on when the timeout is set in relation to execute() in the calling - // context. So we setup to forward clock on next tick. - process.nextTick(forwardClock); - } - - senderExecuteStub.restore(); - senderExecuteStub = sinon.stub( - testSender, - 'execute', - ).callsFake(scheduleForwardClock); - - instanceLookup.instanceLookup(options, (error, port) => { - assert.ok(error.indexOf('Failed to get response') !== -1); - assert.strictEqual(port, undefined); - - assert.strictEqual(createSenderStub.callCount, options.retries); - for (let j = 0; j < createSenderStub.callCount; j++) { - assert.strictEqual(createSenderStub.args[j][0], options.server); - assert.strictEqual(createSenderStub.args[j][3]); - } - - // Execute called 'retries' number of times but parse is never called because - // all the execute calls timeout. - assert.strictEqual(senderExecuteStub.callCount, options.retries); - assert.strictEqual(parseStub.callCount, 0); + describe('when receiving a response that does not contain the requested instance name', function() { + it('returns an error', function(done) { + server.on('message', (msg, rinfo) => { + const response = [ + 'ServerName;WINDOWS2;InstanceName;first;IsClustered;No;Version;10.50.2500.0;tcp;1444;;', + 'ServerName;WINDOWS2;InstanceName;second;IsClustered;No;Version;10.50.2500.0;tcp;1433;;', + 'ServerName;WINDOWS2;InstanceName;third;IsClustered;No;Version;10.50.2500.0;tcp;1445;;' + ].join(''); - clock.restore(); + server.send(response, rinfo.port, rinfo.address); + }); - done(); + new InstanceLookup().instanceLookup({ + server: server.address().address, + port: server.address().port, + instanceName: 'other', + timeout: 500, + retries: 1, + }, (err) => { + assert.isOk(err); + assert.match(err, /^Port for other not found/); + + done(); + }); }); - }), - - it('incorrect instanceName', (done) => { - const message = 'ServerName;WINDOWS2;InstanceName;XXXXXXXXXX;IsClustered;No;Version;10.50.2500.0;tcp;0;;' + - 'ServerName;WINDOWS2;InstanceName;YYYYYYYYYY;IsClustered;No;Version;10.50.2500.0;tcp;0;;'; - senderExecuteStub.callsArgWithAsync(0, null, message); - parseStub - .withArgs(message, options.instanceName); - - instanceLookup.instanceLookup(options, (error, port) => { - assert.ok(error.indexOf('XXXXXXXXXX') === -1); - assert.ok(error.indexOf('YYYYYYYYYY') === -1); - assert.strictEqual(port, undefined); + }); - assert.ok(createSenderStub.calledOnce); - assert.ok(senderExecuteStub.calledOnce); - assert.ok(parseStub.calledOnce); + describe('when receiving an invalid response', function() { + it('returns an error', function(done) { + server.on('message', (msg, rinfo) => { + server.send('foo bar baz', rinfo.port, rinfo.address); + }); - done(); + new InstanceLookup().instanceLookup({ + server: server.address().address, + port: server.address().port, + instanceName: 'other', + timeout: 500, + retries: 1, + }, (err) => { + assert.isOk(err); + assert.match(err, /^Port for other not found/); + + done(); + }); }); }); }); @@ -312,43 +233,43 @@ describe('parseBrowserResponse', function() { }); describe('parseBrowserResponse', function() { - let spy; - - beforeEach(function() { - spy = sinon.spy(dns, 'lookup'); - }); - - afterEach(function() { - sinon.restore(); - }); - it('test IDN Server name', (done) => { + const lookup = sinon.spy(function lookup(hostname, options, callback) { + callback([{ address: '127.0.0.1', family: 4 }]); + }); + const options = { server: '本地主机.ad', instanceName: 'instance', timeout: 500, - retries: 1 + retries: 1, + lookup: lookup }; new InstanceLookup().instanceLookup(options, () => { - assert.ok(spy.called, 'Failed to call dns.lookup on hostname'); - assert.ok(spy.calledWithMatch(punycode.toASCII(options.server)), 'Unexpected hostname passed to dns.lookup'); + sinon.assert.calledOnce(lookup); + sinon.assert.calledWithMatch(lookup, punycode.toASCII(options.server)); done(); }); }); it('test ASCII Server name', (done) => { + const lookup = sinon.spy(function lookup(hostname, options, callback) { + callback([{ address: '127.0.0.1', family: 4 }]); + }); + const options = { server: 'localhost', instanceName: 'instance', timeout: 500, - retries: 1 + retries: 1, + lookup: lookup }; - new InstanceLookup().instanceLookup(options, () => { - assert.ok(spy.called, 'Failed to call dns.lookup on hostname'); - assert.ok(spy.calledWithMatch(options.server), 'Unexpected hostname passed to dns.lookup'); + new InstanceLookup().instanceLookup(options, (err) => { + sinon.assert.calledOnce(lookup); + sinon.assert.calledWithMatch(lookup, options.server); done(); }); diff --git a/test/unit/sender-test.js b/test/unit/sender-test.js index 814f8052e..2f16dffef 100644 --- a/test/unit/sender-test.js +++ b/test/unit/sender-test.js @@ -54,7 +54,9 @@ describe('Sender send to IP address', function() { this.createSocketStub = sinon.stub(Dgram, 'createSocket'); this.createSocketStub.withArgs(udpVersion).returns(this.testSocket); - this.sender = new Sender(ipAddress, anyPort, anyRequest); + this.sender = new Sender(ipAddress, anyPort, function lookup() { + + }, anyRequest); } function sendToIpCommonTestValidation(ipAddress) { @@ -152,7 +154,9 @@ function sendToHostCommonTestSetup(lookupError) { this.strategyCancelStub = sinon.stub(testStrategy, 'cancel'); this.strategyCancelStub.withArgs(); - this.sender = new Sender(anyHost, anyPort, anyRequest); + this.sender = new Sender(anyHost, anyPort, function lookup() { + + }, anyRequest); // Stub out the lookupAll method to prevent network activity from doing a DNS // lookup. Succeeds or fails depending on lookupError.