@mysql/xdevapi
Version:
MySQL Connector/Node.js - A Node.js driver for MySQL using the X Protocol and X DevAPI.
587 lines (457 loc) • 26.2 kB
JavaScript
/*
* Copyright (c) 2016, 2021, Oracle and/or its affiliates.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License, version 2.0, as
* published by the Free Software Foundation.
*
* This program is also distributed with certain software (including
* but not limited to OpenSSL) that is licensed under separate terms,
* as designated in a particular file or component or in included license
* documentation. The authors of MySQL hereby grant you an
* additional permission to link the program and your derivative works
* with the separately licensed software that they have included with
* MySQL.
*
* Without limiting anything contained in the foregoing, this file,
* which is part of MySQL Connector/Node.js, is also subject to the
* Universal FOSS Exception, version 1.0, a copy of which can be found at
* http://oss.oracle.com/licenses/universal-foss-exception.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License, version 2.0, for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
'use strict';
/* eslint-env node, mocha */
const OkHandler = require('../../../lib/Protocol/InboundHandlers/OkHandler');
const MysqlxStub = require('../../../lib/Protocol/Stubs/mysqlx_pb');
const PassThrough = require('stream').PassThrough;
const Scope = require('../../../lib/Protocol/Stubs/mysqlx_notice_pb').Frame.Scope;
const WorkQueue = require('../../../lib/WorkQueue');
const condition = require('../../../lib/Protocol/Wrappers/Messages/Expect/Condition');
const errors = require('../../../lib/constants/errors');
const expect = require('chai').expect;
const td = require('testdouble');
// subject under test needs to be reloaded with replacement fakes
let Client = require('../../../lib/Protocol/Client');
describe('Client', () => {
let on;
beforeEach('create fakes', () => {
on = td.function();
});
afterEach('reset fakes', () => {
td.reset();
});
context('constructor', () => {
it('saves the provided stream as an instance variable', () => {
const stream = { on, foo: 'bar' };
const client = new Client(stream);
return expect(client._stream).to.equal(stream);
});
it('creates a `WorkQueue` instance', () => {
const client = new Client({ on });
return expect(client._workQueue).to.be.an.instanceof(WorkQueue);
});
it('sets `danglingFragment` to `null`', () => {
const client = new Client({ on });
return expect(client._danglingFragment).to.be.null;
});
});
context('decodeMessage()', () => {
it('returns an object containing the message id and data payload', () => {
const client = new Client();
const message = Buffer.from('MySQL X Protocol FTW');
const decodeMessageHeader = td.replace(client, 'decodeMessageHeader');
td.when(decodeMessageHeader(message)).thenReturn({ messageId: 3, packetLength: message.length });
expect(client.decodeMessage(message)).to.deep.equal({ id: 3, payload: message.slice(5) });
});
it('throws an error if the server is using the MySQL classic protocol', () => {
const client = new Client();
const classicServerGreeting = Buffer.from('010000000a', 'hex');
expect(() => client.decodeMessage(classicServerGreeting)).to.throw(errors.MESSAGES.ER_CLIENT_NO_X_PROTOCOL);
});
it('throws an error if the server sends an incomplete protocol message', () => {
const client = new Client();
const message = Buffer.alloc(10);
const decodeMessageHeader = td.replace(client, 'decodeMessageHeader');
td.when(decodeMessageHeader(message)).thenReturn({ packetLength: 11 });
expect(() => client.decodeMessage(message)).to.throw(errors.MESSAGES.ER_DEVAPI_INCOMPLETE_PROTOCOL_MESSAGE);
});
});
context('decodeMessageHeader()', () => {
it('returns an object with the message id and length', () => {
const client = new Client();
const messageId = 5;
const packetLength = 10;
const message = Buffer.allocUnsafe(5);
// The length of the packet is encoded in the the first byte but
// does not include the length of the header itself (4 bytes).
message.writeUInt32LE(packetLength - 4);
// The id is encoded in the byte immediatelly after the header.
message[4] = messageId;
expect(client.decodeMessageHeader(message)).to.deep.equal({ messageId, packetLength });
});
it('throw an error if the header is not a valid X Protocol header', () => {
const client = new Client();
const emptyMessage = Buffer.alloc(0);
expect(() => client.decodeMessageHeader(emptyMessage)).to.throw(errors.MESSAGES.ER_X_CLIENT_UNKNOWN_PROTOCOL_HEADER);
});
});
context('encodeMessage()', () => {
it('returns a Node.js Buffer representation of an X Protocol message', () => {
const client = new Client();
const messageType = 5;
const payload = Buffer.from('MySQL X Protocol FTW!');
const message = client.encodeMessage(messageType, payload);
expect(message).to.be.an.instanceOf(Buffer);
expect(message).to.have.lengthOf(payload.length + 5); // includes 4 bytes for the header and 1 byte for the message id
expect(message.readUInt32LE(0) - 1).to.equal(payload.length); // excludes 1 byte of the message id
expect(message[4]).to.equal(messageType);
});
});
context('handleNetworkFragment()', () => {
let decodeMessage, process, message1, message2, rawMessage1, rawMessage2;
beforeEach('create fakes', () => {
decodeMessage = td.replace(Client.prototype, 'decodeMessage');
process = td.replace(WorkQueue.prototype, 'process');
rawMessage1 = Buffer.allocUnsafe(8);
rawMessage1.writeUInt32LE(4);
rawMessage1.writeUInt8(1, 4);
rawMessage1.fill('foo', 5);
rawMessage2 = Buffer.allocUnsafe(8);
rawMessage2.writeUInt32LE(4);
rawMessage2.writeUInt8(2, 4);
rawMessage2.fill('bar', 5);
message1 = { id: 1, payload: Buffer.from('foo') };
message2 = { id: 2, payload: Buffer.from('bar') };
td.when(process(), { ignoreExtraArgs: true }).thenReturn();
td.when(decodeMessage(rawMessage2), { ignoreExtraArgs: true }).thenReturn(message2);
td.when(decodeMessage(rawMessage1), { ignoreExtraArgs: true }).thenReturn(message1);
});
it('handles messages fully-contained in a fragment', () => {
const client = new Client();
// fragment containing two messages
const fragment = Buffer.concat([rawMessage1, rawMessage2], rawMessage1.length + rawMessage2.length);
client.handleNetworkFragment(fragment);
expect(td.explain(process).callCount).to.equal(2);
expect(td.explain(process).calls[0].args).to.deep.equal([message1]);
expect(td.explain(process).calls[1].args).to.deep.equal([message2]);
});
it('handles message headers split between fragments', () => {
const client = new Client();
const partialHeader = rawMessage2.slice(0, 2);
// fragment containing the first message and a partial header of the second message
const fragment1 = Buffer.concat([rawMessage1, partialHeader], rawMessage1.length + partialHeader.length);
// fragment containing the remaining content
const fragment2 = rawMessage2.slice(2);
client.handleNetworkFragment(fragment1);
client.handleNetworkFragment(fragment2);
expect(td.explain(process).callCount).to.equal(2);
expect(td.explain(process).calls[0].args).to.deep.equal([message1]);
expect(td.explain(process).calls[1].args).to.deep.equal([message2]);
});
it('handles message headers and payloads split between fragments', () => {
const client = new Client();
const header = rawMessage2.slice(0, 4);
// fragment containing the first message and the entire header of the second message
const fragment1 = Buffer.concat([rawMessage1, header], rawMessage1.length + header.length);
// fragment containing the payload of the second message
const fragment2 = rawMessage2.slice(4);
client.handleNetworkFragment(fragment1);
client.handleNetworkFragment(fragment2);
expect(td.explain(process).callCount).to.equal(2);
expect(td.explain(process).calls[0].args).to.deep.equal([message1]);
expect(td.explain(process).calls[1].args).to.deep.equal([message2]);
});
it('handles message payloads split between fragments', () => {
const client = new Client();
const partialMessage = rawMessage2.slice(0, 6);
// fragment containing the first message and a partial payload of the second message
const fragment1 = Buffer.concat([rawMessage1, partialMessage], rawMessage1.length + partialMessage.length);
// fragment containing the remaining content
const fragment2 = rawMessage2.slice(6);
client.handleNetworkFragment(fragment1);
client.handleNetworkFragment(fragment2);
expect(td.explain(process).callCount).to.equal(2);
expect(td.explain(process).calls[0].args).to.deep.equal([message1]);
expect(td.explain(process).calls[1].args).to.deep.equal([message2]);
});
it('handles smaller fragments', () => {
const client = new Client();
const partialMessage = rawMessage1.slice(2);
// fragment containing just a partial header of the first message
const fragment1 = rawMessage1.slice(0, 2);
// fragment the remaining content
const fragment2 = Buffer.concat([partialMessage, rawMessage2], partialMessage.length + rawMessage2.length);
client.handleNetworkFragment(fragment1);
client.handleNetworkFragment(fragment2);
expect(td.explain(process).callCount).to.equal(2);
expect(td.explain(process).calls[0].args).to.deep.equal([message1]);
expect(td.explain(process).calls[1].args).to.deep.equal([message2]);
});
it('handles messages split between more than two fragments', () => {
const client = new Client();
// more than two fragments containing the same mesage
const fragment1 = rawMessage1.slice(0, 4);
const fragment2 = rawMessage1.slice(4, 6);
const fragment3 = rawMessage1.slice(6, 8);
client.handleNetworkFragment(fragment1);
client.handleNetworkFragment(fragment2);
client.handleNetworkFragment(fragment3);
expect(td.explain(process).callCount).to.equal(1);
expect(td.explain(process).calls[0].args).to.deep.equal([message1]);
});
it('handles fragments containing a lot of messages', () => {
const client = new Client();
let fragment = Buffer.alloc(0);
// The stack size on Node.js v4 seems to exceed for around 6035 messages of 8 bytes.
// Let's keep a bit of a margin while making sure the test is fast enough.
for (let i = 0; i < 7000; ++i) {
fragment = Buffer.concat([fragment, rawMessage1], fragment.length + rawMessage1.length);
}
client.handleNetworkFragment(fragment);
expect(td.explain(process).callCount).to.equal(7000);
td.explain(process).calls.forEach(call => expect(call.args).to.deep.equal([message1]));
});
});
context('handleServerMessage()', () => {
let process;
beforeEach('create fakes', () => {
process = td.replace(WorkQueue.prototype, 'process');
});
it('does not process global notices', () => {
const decodeFrame = td.function();
td.replace('../../../lib/Protocol/OutboundHandlers/Notice', { decodeFrame });
const Client = require('../../../lib/Protocol/Client');
const socket = new PassThrough();
const client = new Client(socket);
const decodeMessage = td.replace(client, 'decodeMessage');
const message = { id: MysqlxStub.ServerMessages.Type.NOTICE, payload: 'bar' };
const notice = { scope: Scope.GLOBAL };
td.when(decodeFrame(message.payload)).thenReturn(notice);
td.when(decodeMessage('foo')).thenReturn(message);
client.handleServerMessage('foo');
expect(td.explain(process).callCount).to.equal(0);
});
it('does not process empty notices', () => {
const client = new Client();
const emptyNotice = Buffer.from('010000000b', 'hex');
client.handleServerMessage(emptyNotice);
expect(td.explain(process).callCount).to.equal(0);
});
});
context('sessionClose()', () => {
let FakeOkHandler, encodeClose, encodeMessage;
beforeEach('create fakes', () => {
FakeOkHandler = td.constructor(OkHandler);
encodeClose = td.function();
td.replace('../../../lib/Protocol/InboundHandlers/OkHandler', FakeOkHandler);
td.replace('../../../lib/Protocol/OutboundHandlers/Session', { encodeClose });
Client = require('../../../lib/Protocol/Client');
encodeMessage = td.replace(Client.prototype, 'encodeMessage');
});
it('sends a Mysqlx.Session.Close message to the server', () => {
const client = new Client();
td.when(encodeClose()).thenReturn('foo');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.SESS_CLOSE, 'foo')).thenReturn('bar');
td.when(FakeOkHandler.prototype.sendMessage(td.matchers.anything(), td.matchers.anything(), 'bar')).thenResolve('baz');
return client.sessionClose()
.then(actual => expect(actual).to.equal('baz'));
});
it('fails if there is an error while encoding the message', () => {
const error = new Error('foo');
const client = new Client();
td.when(encodeClose()).thenThrow(error);
return client.sessionClose()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
it('fails if there is an error while sending the message to the server', () => {
const error = new Error('foo');
const client = new Client();
td.when(encodeClose()).thenReturn('foo');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.SESS_CLOSE, 'foo')).thenReturn('bar');
td.when(FakeOkHandler.prototype.sendMessage(), { ignoreExtraArgs: true }).thenReject(error);
return client.sessionClose()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
});
// needs to be called with client.close()
context('connectionClose()', () => {
let FakeOkHandler, encodeMessage, encodeClose;
beforeEach('create fakes', () => {
FakeOkHandler = td.constructor(OkHandler);
encodeClose = td.function();
td.replace('../../../lib/Protocol/InboundHandlers/OkHandler', FakeOkHandler);
td.replace('../../../lib/Protocol/OutboundHandlers/Connection', { encodeClose });
Client = require('../../../lib/Protocol/Client');
encodeMessage = td.replace(Client.prototype, 'encodeMessage');
});
it('sends a Mysqlx.Connection.Close message to the server', () => {
const client = new Client();
td.when(encodeClose()).thenReturn('foo');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.CON_CLOSE, 'foo')).thenReturn('bar');
td.when(FakeOkHandler.prototype.sendMessage(td.matchers.anything(), td.matchers.anything(), 'bar')).thenResolve('baz');
return client.connectionClose()
.then(actual => expect(actual).to.equal('baz'));
});
it('fails if there is an error while encoding the message', () => {
const error = new Error('foo');
const client = new Client();
td.when(encodeClose()).thenThrow(error);
return client.connectionClose()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
it('fails if there is an error while sending the message to the server', () => {
const error = new Error('foo');
const client = new Client();
td.when(encodeClose()).thenReturn('foo');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.CON_CLOSE, 'foo')).thenReturn('bar');
td.when(FakeOkHandler.prototype.sendMessage(td.matchers.anything(), td.matchers.anything(), 'bar')).thenReject(error);
return client.connectionClose()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
});
context('sessionReset()', () => {
let FakeOkHandler, authenticate, encodeMessage, encodeReset, network;
beforeEach('create fakes', () => {
FakeOkHandler = td.constructor(OkHandler);
encodeReset = td.function();
network = new PassThrough();
td.replace('../../../lib/Protocol/OutboundHandlers/Session', { encodeReset });
td.replace('../../../lib/Protocol/InboundHandlers/OkHandler', FakeOkHandler);
Client = require('../../../lib/Protocol/Client');
authenticate = td.replace(Client.prototype, 'authenticate');
encodeMessage = td.replace(Client.prototype, 'encodeMessage');
});
afterEach('reset fakes', () => {
return new Promise(resolve => network.end(resolve));
});
context('in the first call', () => {
let expectOpen, expectClose;
beforeEach('create fakes', () => {
expectOpen = td.replace(Client.prototype, 'expectOpen');
expectClose = td.replace(Client.prototype, 'expectClose');
});
context('with new servers', () => {
it('sets the expectations, resets the session keeping it open and updates the local state', () => {
const client = new Client(network);
const expectations = [{
condition: condition.ACTION.EXPECT_OP_SET,
key: condition.TYPE.EXPECT_FIELD_EXIST,
value: '6.1'
}];
td.when(expectOpen(expectations)).thenResolve();
td.when(encodeReset({ keepOpen: true })).thenReturn('bar');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.SESS_RESET, 'bar')).thenReturn('baz');
td.when(FakeOkHandler.prototype.sendMessage(td.matchers.anything(), network, 'baz')).thenResolve();
td.when(expectClose()).thenResolve();
return client.sessionReset()
.then(() => expect(client._requiresAuthenticationAfterReset).to.equal('NO'));
});
});
context('with old servers', () => {
it('tries to set the server expectations, updates the local state, resets the session and re-authenticates', () => {
const client = new Client(network);
const expectations = [{
condition: condition.ACTION.EXPECT_OP_SET,
key: condition.TYPE.EXPECT_FIELD_EXIST,
value: '6.1'
}];
const error = new Error();
// Error 5168 means the X Plugin does not support prepared statements (fails with the "6.1" expectation)
error.info = { code: 5168 };
td.replace(client, '_authenticator', 'foo');
td.when(expectOpen(expectations)).thenReject(error);
td.when(encodeReset({ keepOpen: false })).thenReturn('bar');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.SESS_RESET, 'bar')).thenReturn('baz');
td.when(FakeOkHandler.prototype.sendMessage(td.matchers.anything(), network, 'baz')).thenResolve();
td.when(authenticate('foo'), { ignoreExtraArgs: true }).thenResolve('qux');
return client.sessionReset()
.then(actual => {
expect(actual).to.equal('qux');
expect(client._requiresAuthenticationAfterReset).to.equal('YES');
});
});
});
it('fails if there is an unexpected error while opening the expectation block', () => {
const client = new Client(network);
const error = new Error();
error.info = { code: -1 };
td.when(expectOpen(), { ignoreExtraArgs: true }).thenReject(error);
return client.sessionReset()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
it('fails if there is an unexpected error while encoding the Mysqlx.Session.Reset message ', () => {
const client = new Client(network);
const error = new Error();
error.info = { code: -1 };
td.when(expectOpen(), { ignoreExtraArgs: true }).thenResolve();
td.when(encodeReset(), { ignoreExtraArgs: true }).thenReturn();
td.when(encodeMessage(), { ignoreExtraArgs: true }).thenThrow(error);
return client.sessionReset()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
it('fails if there is an unexpected error while resetting the connection ', () => {
const client = new Client(network);
const error = new Error();
error.info = { code: -1 };
td.when(expectOpen(), { ignoreExtraArgs: true }).thenResolve();
td.when(encodeReset(), { ignoreExtraArgs: true }).thenReturn();
td.when(encodeMessage(), { ignoreExtraArgs: true }).thenReturn();
td.when(FakeOkHandler.prototype.sendMessage(), { ignoreExtraArgs: true }).thenReject(error);
return client.sessionReset()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
it('fails if there is an unexpected error while closing the expectation block', () => {
const client = new Client(network);
const error = new Error();
error.info = { code: -1 };
td.when(expectOpen(), { ignoreExtraArgs: true }).thenResolve();
td.when(encodeReset(), { ignoreExtraArgs: true }).thenReturn();
td.when(encodeMessage(), { ignoreExtraArgs: true }).thenReturn();
td.when(FakeOkHandler.prototype.sendMessage(), { ignoreExtraArgs: true }).thenResolve();
td.when(expectClose()).thenReject(error);
return client.sessionReset()
.then(() => expect.fail())
.catch(err => expect(err).to.deep.equal(error));
});
});
context('in subsequent calls', () => {
it('resets the session and keeps it open with new servers in subsequent calls', () => {
const client = new Client(network);
// does not require re-authentication
td.replace(client, '_requiresAuthenticationAfterReset', 'NO');
td.when(encodeReset({ keepOpen: true })).thenReturn('bar');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.SESS_RESET, 'bar')).thenReturn('baz');
td.when(FakeOkHandler.prototype.sendMessage(td.matchers.anything(), network, 'baz')).thenResolve('qux');
return client.sessionReset()
.then(actual => expect(actual).to.equal('qux'));
});
it('resets the session and re-authenticates with older servers in subsequent calls', () => {
const client = new Client(network);
td.replace(client, '_authenticator', 'foo');
// requires re-authentication
td.replace(client, '_requiresAuthenticationAfterReset', 'YES');
td.when(encodeReset({ keepOpen: false })).thenReturn('bar');
td.when(encodeMessage(MysqlxStub.ClientMessages.Type.SESS_RESET, 'bar')).thenReturn('baz');
td.when(FakeOkHandler.prototype.sendMessage(td.matchers.anything(), network, 'baz')).thenResolve();
td.when(authenticate('foo')).thenResolve('qux');
return client.sessionReset()
.then(actual => expect(actual).to.equal('qux'));
});
});
});
});