Testing Socket.io + Passport.socketio with Mocha

The usage of real-time socket connection with authentication data seems quite regular thing to me. And, personally, I find it very strange that this topic receives so little interest. While I was struggling with it, the only related publication I found is the blog post by Liam Kaufman: Testing Socket.IO With Mocha, Should.js and Socket.IO Client. It is surely helpful, but I need more: my socket connections have access to cookies and authentication data. I need to test that too.

I also found a couple of questions on StackOverflow:

As I already said, I'm very surprised that the topic receives almost no attention: the aforementioned questions have no answers, no comments (except mine), and almost no upvotes.

So, I had to figure that out myself, and unfortunately it took me a lot of time; much longer that I was expecting. Anyway, now I have it working, and just want to share my experience with the hope it will be useful.

Overview of the problem

Before socket connection is established, client (usually a browser) initiates the handshake request. The browser sends cookies with this request (as with any other request), so, we can register socket.io middleware that gains access to these cookies and sets some data inside the socket connection. And passport.socketio does exactly that: it accesses session information from cookies, retrieves authentication data and stores it in the socket.

The main problem is that in Mocha unit tests we don't use browser, and there is nobody who would send cookies for us. We use socket.io-client, which uses XMLHttpRequest, and at the moment I actually failed to find the legal way to inject cookies to it.

First, I've checked how the tests for passport.socketio are implemented, and I found out that they monkey-patch xmlhttprequest, which is the used by socket.io. But the socket.io is a fast-moving target: a lot has changed since then, and this method does not work now as it used to. And anyway, it is a kind of hack; it's a pity that we still don't have an API for that.

But the passport.socketio-specific solution turned out to be rather simple: as a CORS-Workaround, passport.socketio allows session_id to be given as a query parameter, and this is easily achievable in tests, like this:

    var socket = io(
      socketUrl,
      {
        //-- pass session_id as a query parameter
        query: 'session_id=' + mySessionId
      }
    );

So, this is the feature we're going to exploit.

Example application

Probably the most convenient way to explain testing techniques is to provide an example. So, I've written example AngularJS application: socketio-passport-chat-with-tests . As is often the case with socket.io examples, it's a chat application. But, unlike most other socket.io examples, it uses real authentication with Passport, and the authentication data is available in socket connection.

To run the application, clone the repository, cd to it, and run the following:

npm install
bower install

Now, you can start the application. Note that you need to have running mongodb daemon for it to work.

npm start

Then, navigate in your browser to localhost:7081, and you'll the something like this:

As the hint suggests, you can log in with the following credentials:

  • login: test1 | password: 1
  • login: test2 | password: 2

And post something. Your messages will look like this:

Actually, the application turned out to be a bit more complicated that I was planning: authentication involves some additional stuff. And, by the way, there are a couple of additional challenges that I faced to ensure that socket's authentication data is in sync with cookies. I hope I'll find some time to write a separate blog post about it, but for now, I'm going to focus on testing stuff.

Test objectives

As you may see from the above explanation, we have the following functionality:

  • Users may log in and out;
  • Logged-in users may post messages and read all other posted messages;
  • Non-logged-in users may only read messages, they should not be able to post anything.

So, this is what we're going to test.

Test login / logout

We will login and logout many times in our tests, so, it makes sense to create special helper functions for that. I put these helpers in test/server/unit_helpers/auth.server.test.helper.js:

test/server/unit_helpers/auth.server.test.helper.js
'use strict';
 
/**
 * Perform user login.
 *
 * @param {Object} agent
 *    Agent instance returned by supertest.agent(app)
 * @param {String} username
 * @param {String} password
 * @param {Function} callback
 *    Optional.
 *    If provided, this callback is called when authentication is successful.
 *    It takes 3 arguments:
 *
 *      - res: express's response
 *      - done: callback that must be called when callback is finished
 *        its job
 *      - cbData: arbitrary user data provided as next argument
 *
 * @param {Mixed} cbData
 *    Optional.
 *    Arbitrary user data that is given to callback
 */
module.exports.agentLogin = function(agent, username, password, callback, cbData) {
  it('Should log in as ' + username, function(done){
    agent
    .post('/api/login')
    .send(
      {
        username: username,
        password: password
      }
    )
    .expect(200)
    .expect('Content-Type', /json/)
    .end(function(err, res){
      if (err){
        throw err;
      }
 
      //-- if callback is provided, then call it and give 0100one0032function
      //   to it. The callback **must** call done() eventually, either
      //   directly or indirectly.
      //
      //   If no callback is provided, just call done() right here.
      if (callback){
        callback(res, done, cbData);
      } else {
        done();
      }
    })
    ;
  });
}
 
/**
 * Perform user logout.
 *
 * @param {Object} agent
 *    Agent instance returned by supertest.agent(app)
 */
module.exports.agentLogout = function(agent) {
  it('Should log out', function(done){
    agent
    .get('/api/logout')
    .expect(200)
    .end(function(err, res){
      if (err){
        throw err;
      }
      done();
    })
    ;
  });
}

Test chat functionality

Now, go on to actual chat functionality tests. In the repository, it is stored in the file test/server/unit/chat/chat.server.controller.test.js.

We will play with two users: testUser1 and testUser2. Before tests, we create these users, and after tests, we remove them. Note that the database used for tests is different from production (and development) database, so, we will not interfere with whatever actual data stored in real database.

So, during tests, we will try to send messages by these users, and collect information about each user, like:

  • how many messages were received by user;
  • how many messages were successfully sent by user;
  • how many messages were failed to send.

Also, each user should have its own agent, returned by supertest, in order to keep cookies.

The test plan is divided into 2 parts.

First part: both users are logged in:

  • both users log in
  • both users establish socket connection and subscribe for chat events
  • both users send some messages (as messages are sent/received, user's statistics are updated). Check that all messages are sent, and both users received all messages.
  • both users drop socket connection
  • both users log out

Second part: only one user is logged in:

  • user1 logs in, user2 does not log in
  • both users establish socket connection and subscribe for chat events
  • both users send some messages (as messages are sent/received, user's statistics are updated). User2 should not be able to send messages, but it should still be able to receive other messages.
  • both users drop socket connection
  • user1 logs out

In order to implement them, we need for a couple of building blocks.

Building blocks

Initially, we need to create some dummy credentials and prepare objects for user statistics:

  var testUser1Username = 'user1';
  var testUser1Password = 'mypassword';
  var testUser1Id;  //-- will be set when user is created
  var testUser1;    //-- will be set when user is created
 
  var testUser2Username = 'user2';
  var testUser2Password = 'mypassword';
  var testUser2;    //-- will be set when user is created
  var testUser2Id;  //-- will be set when user is created
 
  //-- some user data that will be collected during tests.
  //   Right now, it just contains agent returned by supertest.
  //
  //   We need for this agent in order to keep cookies.
  //   Of course, each user has its own agent.
  var userData1 = {
    agent: request.agent(app),
  };
  var userData2 = {
    agent: request.agent(app),
  };

And here we have a function that resets (or initializes) statistics for users:

  /**
   * Reset user data: session ids, message statistics, etc.
   * Called before test series.
   */
  function _resetUserDataAll() {
 
    userData1.sessionId = null;
    userData1.socket = null;
    userData1.connected = false;
    userData1.messagesReceived = [];
    userData1.messagesSent = [];
    userData1.messagesFailed = [];
 
    userData2.sessionId = null;
    userData2.socket = null;
    userData2.connected = false;
    userData2.messagesReceived = [];
    userData2.messagesSent = [];
    userData2.messagesFailed = [];
 
  }

There are a couple of functions that perform socket connection passing session_id.

  /**
   * Opens socket connection for particular user, taking in account
   * session id (needed for passport.socketio)
   */
  function _socketConnectUser(userData, checkDone) {
    userData.socket = io(
      socketUrl,
      {
        transports: ['websocket'],
        autoConnect: true,
        forceNew: true,
 
        //-- Crucial thing: pass session_id as a query parameter
        //   to passport.socketio
        query: 'session_id=' + userData.sessionId
      }
    );
 
    userData.socket.on('connect', function (data) {
      userData.connected = true;
      checkDone();
    });
  }
 
  /**
   * For all users, establish socket connection
   */
  function _socketConnectAll() {
    it("Should connect to socket", function(done) {
      //-- open socket
      _socketConnectUser(userData1, checkDone);
      _socketConnectUser(userData2, checkDone);
 
      /*
       * Called after each socket connection. If all users
       * are connected, call 0100one()0010       */
      function checkDone(){
        if (userData1.connected && userData2.connected){
          done();
        }
      }
    });
  }

And a simple function that drops socket connection for both users:

  /**
   * Disconnect all users' sockets
   */
  function _socketDisonnectAll() {
    it("Should disconnect", function(done) {
 
      userData1.socket.disconnect();
      userData2.socket.disconnect();
 
      done();
    });
  }

And now, main function in which we send messages by users. If you look at the two parts of the test plan described above, you'll see that they are actually quite similar: the only difference is that in the second part, user2 is not logged in. In either case, both users should try to send messages, but we expect different statistics: when user is not logged in, he/she is not able to send messages.

So, it makes sense to define some common function that will send messages and check statistics depending on given parameter.

Before we proceed, there is a list of socket events emitted by the server:

  • chatMessage: new chat message received. This event is emitted to all connected clients whenever someone posts new message.
  • chatMessageSent: message is sent successfully. This event is emitted just to a single user, when he/she posts new message.
  • chatMessageSendError: message sending failed. This event is emitted just to a single user, when he/she tries to post message illegally.

Here we go:

  /**
   * Try to broadcast messages, and check the following:
   *
   *   - Messages from logged-in users are broadcasted
   *   - Messages from non-logged-in users are NOT broadcasted
   *   - Users get notified about whether or not message
   *     sending was successful
   * 
   * @param {Boolean} user2LoggedIn
   *    This argument affects statistic expectations: if user2 is logged in,
   *    then it should be able to post messages; otherwise, it shouldn't.
   *
   */
  function _tryBroadcastMessages(user2LoggedIn) {
    it("Should broadcast messages", function(done) {
 
      //-- message that we're going to send by different users
      var chatMessage = {
        text:             'hello',
        messageClientId:  123,
      };
 
      /*
       * Subscribe on chat events: when something happens,
       * update user's statistics.
       */
      var subscribeOnChatMessage = function(userData){
 
        //-- new chat message received
        userData.socket.on('chatMessage', function(msg){
          msg.text.should.equal(chatMessage.text);
 
          //-- remember received message
          userData.messagesReceived.push(msg);
        });
 
        //-- chat message sent
        userData.socket.on('chatMessageSent', function(data){
 
          data.messageClientId.should.equal(chatMessage.messageClientId);
 
          //-- remember sent message
          userData.messagesSent.push(data);
        });
 
        //-- chat message sending failed
        userData.socket.on('chatMessageSendError', function(data){
 
          data.messageClientId.should.equal(chatMessage.messageClientId);
 
          //-- remember failed message
          userData.messagesFailed.push(data);
        });
      };
 
      //-- subscribe both users on chat events
      subscribeOnChatMessage(userData1);
      subscribeOnChatMessage(userData2);
 
      //-- send some messages by users:
      userData1.socket.emit('chatMessage', chatMessage);
      userData1.socket.emit('chatMessage', chatMessage);
 
      userData2.socket.emit('chatMessage', chatMessage);
 
      //-- After some time (100 ms), check user statistics.
      //
      //   Note: we use setTimeout here intead of checking current numbers
      //   every time socket event is received, because it would not
      //   catch the error if we get more events than expected.
      //
      //   Plus, it gives us much more informative error messages,
      //   unlike plain "timeout of 2000 ms exceeded".
      var checkTimerId = setTimeout(
        function () {
          if (user2LoggedIn){
            //-- both users should receive messages from both of them
            userData1.messagesReceived.length.should.equal(3);
            userData2.messagesReceived.length.should.equal(3);
 
            //-- user 1 should have 2 messages sent,
            //   user 2 should have 1 message sent.
            userData1.messagesSent.length.should.equal(2);
            userData2.messagesSent.length.should.equal(1);
 
            //-- nobody should have any failed messages
            userData1.messagesFailed.length.should.equal(0);
            userData2.messagesFailed.length.should.equal(0);
          } else {
            //-- both users should receive only messages from user 1
            //   (even though user 2 is not logged in, he/she should still
            //   receive messages), but messages from user 2 should be ignored
            userData1.messagesReceived.length.should.equal(2);
            userData2.messagesReceived.length.should.equal(2);
 
            //-- user 1 should have 2 messages sent,
            //   but user 2 should not have any messages sent
            userData1.messagesSent.length.should.equal(2);
            userData2.messagesSent.length.should.equal(0);
 
            //-- user 1 should not have failed messages,
            //   but user 2 should have.
            userData1.messagesFailed.length.should.equal(0);
            userData2.messagesFailed.length.should.equal(1);
          }
 
          done();
        },
        100
      );
 
    });
  }

Now, let's write some describe blocks with before/after routines: as I already mentioned, we need to create test users before tests, and remove them afterwards:

  describe("Chat server controller", function () {
 
    before(function(done) {
 
      //-- create test users
 
      testUser1 = new User({
        username:   testUser1Username,
        password:   testUser1Password,
      });
      testUser1.save(function(user) {
        testUser1Id = testUser1.id;
        //-- if both users were created, continue
        _checkUsersCreated();
      });
 
      testUser2 = new User({
        username:   testUser2Username,
        password:   testUser2Password,
      });
      testUser2.save(function(user) {
        testUser2Id = testUser2.id;
        //-- if both users were created, continue
        _checkUsersCreated();
      });
 
      //-- if both users were created, continue
      function _checkUsersCreated() {
        if (testUser1Id && testUser2Id){
          done();
        }
      }
    });
 
    //-- after tests, remove all users
    after(function(done) {
      User.remove(function() {
        done();
      });
    });
 
 
    describe("Both users logged in", function () {
      /* see below */
    });
 
 
 
    describe("User1 is logged in, but User2 is not", function () {
      /* see below */
    });
 
  });

Now, after all, we can write exact test sequences.

Exact test sequences

First of all, part 1:

    describe("Both users logged in", function () {
 
      //-- before tests, reset user data
      before(function(done) {
        _resetUserDataAll();
        done();
      });
 
 
      //-- login user1
      authHelper.agentLogin(
        userData1.agent,
        testUser1Username,
        testUser1Password,
        _agentLoginCallback,
        userData1
      );
 
      //-- login user2
      authHelper.agentLogin(
        userData2.agent,
        testUser2Username,
        testUser2Password,
        _agentLoginCallback,
        userData2
      );
 
 
 
      //-- connect both clients to socket
      _socketConnectAll();
 
      //-- try to broadcast messages, user2 is logged in
      _tryBroadcastMessages(true);
 
      //-- disconnect from socket
      _socketDisonnectAll();
 
 
      //-- logout user1
      authHelper.agentLogout(
        userData1.agent
      );
 
      //-- logout user2
      authHelper.agentLogout(
        userData2.agent
      );
 
    });  

And part 2:

    describe("User1 is logged in, but User2 is not", function () {
 
      //-- before tests, reset user data
      before(function(done) {
        _resetUserDataAll();
        done();
      });
 
      //-- login user1
      authHelper.agentLogin(
        userData1.agent,
        testUser1Username,
        testUser1Password,
        _agentLoginCallback,
        userData1
      );
 
      //-- connect both clients to socket
      _socketConnectAll();
 
      //-- try to broadcast messages, user2 is not logged in
      _tryBroadcastMessages(false);
 
      //-- disconnect clients from sockets
      _socketDisonnectAll();
 
      //-- logout user1
      authHelper.agentLogout(
        userData1.agent
      );
 
 
    });

Result

Done! When we run tests by typing npm test, we have the following log:

  Chat server controller
    Both users logged in
      ✓ Should log in as user1 (167ms)
      ✓ Should log in as user2 (116ms)
      ✓ Should connect to socket (65ms)
      ✓ Should broadcast messages (106ms)
      ✓ Should disconnect
      ✓ Should log out
      ✓ Should log out
    User1 is logged in, but User2 is not
      ✓ Should log in as user1 (101ms)
      ✓ Should connect to socket
      ✓ Should broadcast messages (102ms)
      ✓ Should disconnect
      ✓ Should log out


  12 passing (1s)

That's great: after all, We've got socket.io + passport.socketio tested by Mocha.

Again, you can find complete example application with these tests in the github repository, see the section Example application above.

Discussion

sandeep, 2015/10/06 13:48

This is so awesome ! Thanks a bunch for sharing it. Clearly, you've put in tons of meticulous effort. Btw, how long did it take for you to create this ? For e.g. I had no idea that this 'userData.sessionId = sessionIdSigned.slice(0, sessionIdSigned.lastIndexOf('.')).slice(2)' needed to be done.

Dmitry Frank, 2015/10/06 16:12

Thanks for the comment.

Unfortunately it took a lot of time: probably a couple of days. As to userData.sessionId = sessionIdSigned.slice(0, sessionIdSigned.lastIndexOf('.')).slice(2), it's clear if we know how do signed cookies work: the value you store as a cookie is augmented by the signature before actually being stored. Of course, we could avoid such a hackery with slice and “unsign” the cookie properly, but for test case, I decided not to care.

Charles, 2016/10/21 18:57

Hi Dmitry,

I'm struggling a bit to understand when and how userData.sessionId is being populated. sandeep seems to have figured it out but I'm at a loss. And to be clear, your socket session is sharing a session id with the agent, correct?

Dmitry Frank, 2016/10/21 19:11

Hi Charles,

UPD: see the next comment for a real answer.

Actually, after more than a year, I barely remember what it's all about, since I never had to deal with this stuff again. So I might have mistunderstood the question; however, if I get the question right, then, the answer is: we pass userData.sessionId as a query string parameter when creating a websocket instance. Here:

    userData.socket = io(
      socketUrl,
      {
        transports: ['websocket'],
        autoConnect: true,
        forceNew: true,
 
        //-- Crucial thing: pass session_id as a query parameter
        //   to passport.socketio
        query: 'session_id=' + userData.sessionId
      }
    );

Or, what do you mean?

Dmitry Frank, 2016/10/21 19:27

Ah, it seems I get the question. And, by the way, I found that, weirdly enough, I indeed did not explain it well in the article. One needs to check the example project I prepared at github to get the full picture.

Look at chat.server.controller.test.js file, function _agentLoginCallback(): we get (signed) cookie from the agent, “unsign” it in a hackish manner, and store in userData.sessionId:

    var sessionIdSigned = unescape(
      userData.agent.jar.getCookie(
        config.sidCookieName,
        accessInfo
      ).value
    );

    //-- get cookie value without checking the hash, like 'my-session-id-bla-bla'
    userData.sessionId = sessionIdSigned
    .slice(0, sessionIdSigned.lastIndexOf('.')) //-- remove hash
    .slice(2) //-- remove "s:" from the beginning
    ;

And, answering the second part of your question:

And to be clear, your socket session is sharing a session id with the agent, correct?

Yes, sure.

I hope now it's more clear to you. If not, feel free to ask more questions! :)

Léo, 2018/04/27 14:00

You made my day !

Enter your comment (please, English only). Wiki syntax is allowed:
   ___   ____   _____   ___   __  __
  / _ ) / __ \ / ___/  / _ \ / / / /
 / _  |/ /_/ // (_ /  / ___// /_/ / 
/____/ \____/ \___/  /_/    \____/
 
articles/socketio_passport_testing.txt · Last modified: 2015/09/26 18:30 by dfrank
Driven by DokuWiki Recent changes RSS feed Valid CSS Valid XHTML 1.0