UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

583 lines (419 loc) 17.5 kB
const _ = require('lodash'); const chai = require('chai'); const expect = chai.expect; const rewire = require('rewire'); const sinon = require('sinon'); const sinonChai = require('sinon-chai'); chai.use(sinonChai); const dir = process['test-dir'] || '../../src'; const Packet = require(dir + '/Packet'); const ResourceRecord = require(dir + '/ResourceRecord'); const RType = require(dir + '/constants').RType; const Fake = require('../Fake'); const ServiceResolver = rewire(dir + '/ServiceResolver'); describe('ServiceResolver', function() { const fullname = 'Instance (2)._service._tcp.local.'; const target = 'Target.local.'; const type = '_service._tcp.local.'; const PTR = new ResourceRecord.PTR({name: type, PTRDName: fullname}); const SRV = new ResourceRecord.SRV({name: fullname, target: target, port: 8000}); const TXT = new ResourceRecord.TXT({name: fullname}); const AAAA = new ResourceRecord.AAAA({name: target, address: '::1'}); const A = new ResourceRecord.A({name: target, address: '1.1.1.1'}); const intf = new Fake.NetworkInterface(); intf.cache = new Fake.ExpRecCollection(); const query = new Fake.Query(); const QueryConstructor = sinon.stub().returns(query); ServiceResolver.__set__('Query', QueryConstructor); beforeEach(function() { intf.reset(); intf.cache.reset(); query.reset(); QueryConstructor.reset(); }); describe('#constructor()', function() { it('should parse fullname / make new FSM', sinon.test(function() { const resolver = new ServiceResolver(fullname, []); expect(resolver.instance).to.equal('Instance (2)'); expect(resolver.serviceType).to.equal('_service'); expect(resolver.protocol).to.equal('_tcp'); expect(resolver.domain).to.equal('local'); expect(resolver.transition).to.be.a.function; })); }); describe('#service()', function() { it('should return the same obj each time (updated props)', function() { const resolver = new ServiceResolver(fullname, intf); expect(resolver.service()).to.equal(resolver.service()); }); it('should return the right stuff', function() { const resolver = new ServiceResolver(fullname, intf); expect(resolver.service()).to.eql({ fullname : fullname, name : 'Instance (2)', type : {name: 'service', protocol: 'tcp'}, domain : 'local', host : null, port : null, addresses: [], txt : {}, txtRaw : {}, }); }); it('should remove service type underscore only if needed', function() { const name = 'Instance (2).service._tcp.local.'; const resolver = new ServiceResolver(name, intf); expect(resolver.service().type).to.eql({name: 'service', protocol: 'tcp'}); }); it('should freeze address/txt/txtRaw so they can\'t be modified', function() { const resolver = new ServiceResolver(fullname, intf); resolver.txt = {}; resolver.txtRaw = {}; const service = resolver.service(); service.addresses.push('something'); service.txt.key = 'added!'; service.txtRaw.key = 'added!'; expect(service.addresses).to.not.eql(resolver.addresses); expect(service.txt).to.not.eql(resolver.txt); expect(service.txtRaw).to.not.eql(resolver.txtRaw); }); }); describe('#once()', function() { it('should add a listener that gets removed after one use', function(done) { const resolver = new ServiceResolver(fullname, intf); // should only get called once (or mocha errs) resolver.once('event', (one, two) => { expect(one).to.equal(1); expect(two).to.equal(2); done(); }); resolver.emit('event', 1, 2); resolver.emit('event'); }); }); describe('#_addListeners()', function() { it('should listen to intf and intf cache', function(done) { const resolver = new ServiceResolver(fullname, intf); const allCalled = _.after(4, done); sinon.stub(resolver, 'transition', allCalled); sinon.stub(resolver, '_onAnswer' , allCalled); sinon.stub(resolver, '_onReissue', allCalled); sinon.stub(resolver, '_onExpired', allCalled); resolver._removeListeners(); resolver._addListeners(); intf.emit('answer'); intf.emit('error'); intf.cache.emit('reissue'); intf.cache.emit('expired'); }); }); describe('#_onReissue()', function() { const resolver = new ServiceResolver(fullname, intf); sinon.stub(resolver, 'handle'); it('should ignore irrelevant records', function() { const ignore = new ResourceRecord.A({name: 'ignore!'}); resolver._onReissue(ignore); expect(resolver.handle).to.not.have.been.called; }); it('should pass relevant records to handle fn (name)', function() { resolver._onReissue(SRV); expect(resolver.handle).to.have.been.calledWith('reissue', SRV); }); it('should pass relevant records to handle fn (target)', function() { resolver.target = 'Target.local.'; resolver._onReissue(A); expect(resolver.handle).to.have.been.calledWith('reissue', A); }); it('should pass relevant records to handle fn (PTR)', function() { resolver._onReissue(PTR); expect(resolver.handle).to.have.been.calledWith('reissue', PTR); }); }); describe('#_onExpired()', function() { it('should ignore irrelevant records', function() { const resolver = new ServiceResolver(fullname, intf); sinon.stub(resolver, 'transition'); const ignore = new ResourceRecord.A({name: 'ignore!'}); resolver._onExpired(ignore); expect(resolver.transition).to.not.have.been.called; }); it('should stop if PTR or SRV expires', function() { const resolver = new ServiceResolver(fullname, intf); sinon.stub(resolver, 'transition'); resolver._onExpired(SRV); resolver._onExpired(PTR); expect(resolver.transition).to.have.been .calledTwice .calledWith('stopped'); }); it('should remove dying addresses and unresolve if needed', function() { const resolver = new ServiceResolver(fullname, intf); sinon.stub(resolver, 'transition'); resolver.target = 'Target.local.'; resolver.addresses = ['1.1.1.1', '::1']; resolver._onExpired(A); expect(resolver.transition).to.not.have.been.called; expect(resolver.addresses).to.eql(['::1']); resolver._onExpired(AAAA); expect(resolver.transition).to.have.been.calledWith('unresolved'); expect(resolver.addresses).to.be.empty; }); it('should clear TXT data if TXT record dies', function() { const resolver = new ServiceResolver(fullname, intf); sinon.stub(resolver, 'transition'); resolver._onExpired(TXT); expect(resolver.transition).to.been.calledWith('unresolved'); }); }); describe('#_processRecords()', function() { it('should handle SRV changes', function() { const resolver = new ServiceResolver(fullname, intf); resolver.port = 9999; resolver.target = 'Target'; resolver.addresses = ['1.1.1.1']; expect(resolver._processRecords([SRV])).to.be.true; expect(resolver.port).to.equal(8000); expect(resolver._processRecords([SRV])).to.be.false; // unchanged const change = new ResourceRecord.SRV({ name: fullname, target: 'changed.local.', }); expect(resolver._processRecords([change])).to.be.true; expect(resolver.target).to.equal('changed.local.'); expect(resolver.addresses).to.be.empty; }); it('should handle address record changes', function() { const resolver = new ServiceResolver(fullname, intf); resolver.target = target; resolver.addresses = ['1.1.1.1']; const more = new ResourceRecord.A({name: target, address: '2.2.2.2'}); resolver._processRecords([A]); expect(resolver.addresses).to.eql(['1.1.1.1']); // unchanged resolver._processRecords([AAAA, more]); expect(resolver.addresses).to.eql(['1.1.1.1', '2.2.2.2', '::1']); }); it('should handle TXT record changes', function() { const resolver = new ServiceResolver(fullname, intf); resolver.txt = {}; resolver.txtRaw = {}; const change = new ResourceRecord.TXT({name: fullname, txt: {key: 'value'}}); expect(resolver._processRecords([change])).to.be.true; expect(resolver.txt).to.eql(change.txt); expect(resolver.txtRaw).to.eql(change.txtRaw); expect(resolver._processRecords([change])).to.be.false; // unchanged }); it('should ignore irrelevant records', function() { const resolver = new ServiceResolver(fullname, intf); const ignore = new ResourceRecord.PTR({name: 'ignore!'}); expect(resolver._processRecords([ignore])).to.be.false; }); it('should ignore TTL=0 goodbye records', function() { const resolver = new ServiceResolver(fullname, intf); const goodbye = SRV.clone(); goodbye.ttl = 0; expect(resolver._processRecords([goodbye])).to.be.false; expect(resolver.target).to.be.null; }); }); describe('#_queryForMissing()', function() { const resolver = new ServiceResolver(fullname, intf); sinon.stub(resolver, 'handle'); beforeEach(function() { resolver.target = null; resolver.txtRaw = null; resolver.addresses = []; }); it('should get missing SRV/TXTs', function() { resolver.target = null; resolver._queryForMissing(); expect(query.add).to.have.been.calledonce; expect(query.add.firstCall.args[0]).to.have.lengthOf(2); }); it('should get missing A/AAAAs', function() { resolver.target = 'Target.local.'; resolver.txtRaw = {}; resolver._queryForMissing(); expect(query.add).to.have.been.calledonce; expect(query.add.firstCall.args[0]).to.have.lengthOf(2); }); it('should get missing TXT/A/AAAAs', function() { resolver.target = 'Target.local.'; resolver._queryForMissing(); expect(query.add).to.have.been.calledonce; expect(query.add.firstCall.args[0]).to.have.lengthOf(3); }); it('should check interface caches before sending queries', function() { intf.cache.find.returns([TXT]); resolver.target = 'Target.local.'; resolver.addresses = ['1.1.1.1']; resolver._queryForMissing(); // <- will try to find TXT record expect(resolver.handle).to.have.been.calledWith('incomingRecords', [TXT]); expect(query.add).to.not.have.been.called; intf.cache.find.resetBehavior(); }); }); describe('Sanity checks:', function() { it('should resolve w/ all needed starting records', function(done) { const resolver = new ServiceResolver(fullname, intf); resolver.once('resolved', function() { expect(resolver.addresses).to.eql(['1.1.1.1', '::1']); expect(resolver.target).to.equal(target); expect(resolver.port).to.equal(8000); expect(resolver.txt).to.eql({}); expect(resolver.isResolved()).to.be.true; done(); }); expect(resolver.isResolved()).to.be.false; resolver.start([PTR, SRV, TXT, A, AAAA]); }); it('should not need/ask for AAAA in this case', function() { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT, A]); expect(QueryConstructor).to.not.have.been.called; }); it('should ask for address records', function() { const resolver = new ServiceResolver(fullname, intf); resolver.start([PTR, SRV, TXT]); expect(query.add).to.have.been.calledWithMatch([ {name: target, qtype: RType.A}, {name: target, qtype: RType.AAAA}, ]); }); it('should check intf caches for answers first', function() { intf.cache.find.returns([A]); const resolver = new ServiceResolver(fullname, intf); resolver.start([PTR, SRV, TXT]); expect(query.add).to.have.been.calledWithMatch([ {name: target, qtype: RType.AAAA}, ]); intf.cache.find.resetBehavior(); }); it('should ask for SRV and ignore A/AAAAs (target unknown)', function() { const resolver = new ServiceResolver(fullname, intf); resolver.start([TXT, A, AAAA]); expect(resolver.target).to.be.nil; expect(resolver.addresses).to.be.empty; expect(query.add).to.have.been.calledWithMatch([ {name: fullname, qtype: RType.SRV} ]); }); it('should resolve when needed answers come', function(done) { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT]); resolver.on('resolved', done); const packet = new Packet(); packet.setAnswers([A, AAAA]); intf.emit('answer', packet); }); it('should change queries if needed info changes', function() { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT]); // unresolved const updated = new ResourceRecord.SRV({ name: fullname, target: 'Updated Target.local.', port: 8000, }); const packet = new Packet(); packet.setAnswers([updated]); intf.emit('answer', packet); expect(query.add).to.have.been.calledWithMatch([ {name: 'Updated Target.local.', qtype: RType.A}, {name: 'Updated Target.local.', qtype: RType.AAAA}, ]); }); it('should unresolve w/ incomplete changes (new SRV no A/AAAA)', function() { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT, A, AAAA]); // is now resolved const updated = new ResourceRecord.SRV({ name: fullname, target: 'Updated Target.local.', port: 8000, }); const packet = new Packet(); packet.setAnswers([updated]); intf.emit('answer', packet); expect(resolver.state).to.equal('unresolved'); }); it('should notify when service info gets updated', function(done) { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT, A, AAAA]); // is now resolved resolver.on('updated', function() { expect(resolver.port).to.equal(1111); done(); }); const updated = new ResourceRecord.SRV({ name: fullname, target: target, port: 1111, // <- new port }); const packet = new Packet(); packet.setAnswers([updated]); intf.emit('answer', packet); }); it('should query for updates as records get stale', sinon.test(function() { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT, A, AAAA]); // is now resolved intf.cache.emit('reissue', SRV); intf.cache.emit('reissue', TXT); intf.cache.emit('reissue', A); // wait for batch timer this.clock.tick(1000); expect(query.add).to.have.been.calledWithMatch([ {name: fullname, qtype: RType.SRV}, {name: type, qtype: RType.PTR}, {name: fullname, qtype: RType.TXT}, {name: target, qtype: RType.A}, ]); })); it('should query for reissue updates when unresolved too', sinon.test(function() { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT, A, AAAA]); // is now resolved intf.cache.emit('expired', TXT); expect(resolver.isResolved()).to.be.false; intf.cache.emit('reissue', SRV); intf.cache.emit('reissue', A); // wait for batch timer this.clock.tick(1000); expect(query.add).to.have.been.calledWithMatch([ {name: fullname, qtype: RType.SRV}, {name: type, qtype: RType.PTR}, {name: target, qtype: RType.A}, ]); })); it('should go down if the SRV dies (notified from cache)', function(done) { const resolver = new ServiceResolver(fullname, intf); resolver.start([SRV, TXT, A, AAAA]); // is now resolved resolver.on('down', done); intf.cache.emit('expired', SRV); }); it('should go down if the SRV dies, even if unresolved', function(done) { const resolver = new ServiceResolver(fullname, intf); resolver.start(); resolver.on('down', done); intf.cache.emit('expired', SRV); }); it('should ignore interface and cache events in stopped state', function() { const resolver = new ServiceResolver(fullname, intf); sinon.stub(resolver, '_onAnswer'); sinon.stub(resolver, '_onReissue'); resolver.stop(); intf.emit('answer'); intf.cache.emit('reissue'); expect(resolver._onAnswer).to.not.have.been.called; expect(resolver._onReissue).to.not.have.been.called; }); it('should fail and stop if it can\'t resolve within 10s', sinon.test(function() { const resolver = new ServiceResolver(fullname, intf); resolver.start(); expect(resolver.state).to.equal('unresolved'); this.clock.tick(10 * 1000); expect(resolver.state).to.equal('stopped'); })); it('stopped state should be terminal', function() { const resolver = new ServiceResolver(fullname, intf); resolver.stop(); resolver.start(); expect(resolver.state).to.equal('stopped'); }); }); });