UNPKG

browserchannel

Version:
1,050 lines (910 loc) 70.5 kB
# # Unit tests for BrowserChannel server # # This contains all the unit tests to make sure the server works like it # should. The tests are written using mocha - you can run them using # % npm test # # For now I'm not going to add in any SSL testing code. I should probably generate # a self-signed key pair, start the server using https and make sure that I can # still use it. # # I'm also missing integration tests. http = require 'http' assert = require 'assert' querystring = require 'querystring' express = require 'express' timer = require 'timerstub' browserChannel = require('..').server browserChannel._setTimerMethods timer # This function provides an easy way for tests to create a new browserchannel server using # connect(). # # I'll create a new server in the setup function of all the tests, but some # tests will need to customise the options, so they can just create another server directly. createServer = (opts, method, callback) -> # Its possible to use the browserChannel middleware without specifying an options # object. This little createServer function will mirror that behaviour. if typeof opts == 'function' [method, callback] = [opts, method] # I want to match up with how its actually going to be used. bc = browserChannel method else bc = browserChannel opts, method # The server is created using connect middleware. I'll simulate other middleware in # the stack by adding a second handler which responds with 200, 'Other middleware' to # any request. app = express() app.use bc app.use (req, res, next) -> # I might not actually need to specify the headers here... (If you don't, nodejs provides # some defaults). res.writeHead 200, 'OK', 'Content-Type': 'text/plain' res.end 'Other middleware' # Calling server.listen() without a port lets the OS pick a port for us. I don't # know why more testing frameworks don't do this by default. server = http.createServer(app).listen undefined, '127.0.0.1', -> # Obviously, we need to know the port to be able to make requests from the server. # The callee could check this itself using the server object, but it'll always need # to know it, so its easier pulling the port out here. port = server.address().port callback server, port, bc # Wait for the function to be called a given number of times, then call the callback. # # This useful little method has been stolen from ShareJS expectCalls = (n, callback) -> return callback() if n == 0 remaining = n -> remaining-- if remaining == 0 callback() else if remaining < 0 throw new Error "expectCalls called more than #{n} times" # This returns a function that calls done() after it has been called n times. Its # useful when you want a bunch of mini tests inside one test case. makePassPart = (test, n) -> expectCalls n, -> done() # Most of these tests will make HTTP requests. A lot of the time, we don't care about the # timing of the response, we just want to know what it was. This method will buffer the # response data from an http response object and when the whole response has been received, # send it on. buffer = (res, callback) -> data = [] res.on 'data', (chunk) -> #console.warn chunk.toString() data.push chunk.toString 'utf8' res.on 'end', -> callback? data.join '' # For some tests we expect certain data, delivered in chunks. Wait until we've # received at least that much data and strcmp. The response will probably be used more, # afterwards, so we'll make sure the listener is removed after we're done. expect = (res, str, callback) -> data = '' res.on 'end', endlistener = -> # This should fail - if the data was as long as str, we would have compared them # already. Its important that we get an error message if the http connection ends # before the string has been received. console.warn 'Connection ended prematurely' assert.strictEqual data, str res.on 'data', listener = (chunk) -> # I'm using string += here because the code is easier that way. data += chunk.toString 'utf8' #console.warn JSON.stringify data #console.warn JSON.stringify str if data.length >= str.length assert.strictEqual data, str res.removeListener 'data', listener res.removeListener 'end', endlistener callback() # A bunch of tests require that we wait for a network connection to get established # before continuing. # # We'll do that using a setTimeout with plenty of time. I hate adding delays, but I # can't see another way to do this. # # This should be plenty of time. I might even be able to reduce this. Note that this # is a real delay, not a stubbed out timer like we give to the server. soon = (f) -> setTimeout f, 10 readLengthPrefixedString = (res, callback) -> data = '' length = null res.on 'data', listener = (chunk) -> data += chunk.toString 'utf8' if length == null # The number of bytes is written in an int on the first line. lines = data.split '\n' # If lines length > 1, then we've read the first newline, which was after the length # field. if lines.length > 1 length = parseInt lines.shift() # Now we'll rewrite the data variable to not include the length. data = lines.join '\n' if data.length == length res.removeListener 'data', listener callback data else if data.length > length console.warn data throw new Error "Read more bytes from stream than expected" # The backchannel is implemented using a bunch of messages which look like this: # # ``` # 36 # [[0,["c","92208FBF76484C10",,8] # ] # ] # ``` # # (At least, thats what they look like using google's server. On mine, they're properly # formatted JSON). # # Each message has a length string (in bytes) followed by a newline and some JSON data. # They can optionally have extra chunks afterwards. # # This format is used for: # # - All XHR backchannel messages # - The response to the initial connect (XHR or HTTP) # - The server acknowledgement to forward channel messages # # This is not used when you're on IE, for normal backchannel requests. On IE, data is sent # through javascript calls from an iframe. readLengthPrefixedJSON = (res, callback) -> readLengthPrefixedString res, (data) -> callback JSON.parse(data) # Copied from google's implementation. The contents of this aren't actually relevant, # but I think its important that its pseudo-random so if the connection is compressed, # it still recieves a bunch of bytes after the first message. ieJunk = "7cca69475363026330a0d99468e88d23ce95e222591126443015f5f462d9a177186c8701fb45a6ffe\ e0daf1a178fc0f58cd309308fba7e6f011ac38c9cdd4580760f1d4560a84d5ca0355ecbbed2ab715a3350fe0c47\ 9050640bd0e77acec90c58c4d3dd0f5cf8d4510e68c8b12e087bd88cad349aafd2ab16b07b0b1b8276091217a44\ a9fe92fedacffff48092ee693af\n" suite 'server', -> # #### setup # # Before each test has run, we'll start a new server. The server will only live # for that test and then it'll be torn down again. # # This makes the tests run more slowly, but not slowly enough that I care. setup (callback) -> # This will make sure there's no pesky setTimeouts from previous tests kicking around. # I could instead do a timer.waitAll() in tearDown to let all the timers run & time out # the running sessions. It shouldn't be a big deal. timer.clearAll() # When you instantiate browserchannel, you specify a function which gets called # with each session that connects. I'll proxy that function call to a local function # which tests can override. @onSession = (session) -> # The proxy is inline here. Also, I <3 coffeescript's (@server, @port) -> syntax here. # That will automatically set this.server and this.port to the callback arguments. # # Actually calling the callback starts the test. createServer ((session) => @onSession session), (@server, @port, @bc) => # TODO - This should be exported from lib/server @standardHeaders= 'Content-Type': 'text/plain' 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' 'Pragma': 'no-cache' 'Expires': 'Fri, 01 Jan 1990 00:00:00 GMT' 'X-Content-Type-Options': 'nosniff' # I'll add a couple helper methods for tests to easily message the server. @get = (path, callback) => http.get {host:'localhost', path, @port}, callback @post = (path, data, callback) => req = http.request {method:'POST', host:'localhost', path, @port}, callback req.end data # One of the most common tasks in tests is to create a new session for # some reason. @connect is a little helper method for that. It simply sends the # http POST to create a new session and calls the callback when the session has been # created by the server. # # It also makes @onSession throw an error - very few tests need multiple sessions, # so I special case them when they do. # # Its kind of annoying - for a lot of tests I need to do custom logic in the @post # handler *and* custom logic in @onSession. So, callback can be an object specifying # callbacks for each if you want that. Its a shame though, it makes this function # kinda ugly :/ @connect = (callback) => # This connect helper method is only useful if you don't care about the initial # post response. @post '/channel/bind?VER=8&RID=1000&t=1', 'count=0' @onSession = (@session) => @onSession = -> throw new Error 'onSession() called - I didn\'t expect another session to be created' # Keep this bound. I think there's a fancy way to do this in coffeescript, but # I'm not sure what it is. callback.call this # Finally, start the test. callback() teardown (callback) -> # #### tearDown # # This is called after each tests is done. We'll tear down the server we just created. # # The next test is run once the callback is called. I could probably chain the next # test without waiting for close(), but then its possible that an exception thrown # in one test will appear after the next test has started running. Its easier to debug # like this. @server.close callback # The server hosts the client-side javascript at /channel.js. It should have headers set to tell # browsers its javascript. # # Its self contained, with no dependancies on anything. It would be nice to test it as well, # but we'll do that elsewhere. test 'The javascript is hosted at channel/bcsocket.js', (done) -> @get '/channel/bcsocket.js', (response) -> assert.strictEqual response.statusCode, 200 assert.strictEqual response.headers['content-type'], 'application/javascript' assert.ok response.headers['etag'] buffer response, (data) -> # Its about 47k. If the size changes too drastically, I want to know about it. assert.ok data.length > 45000, "Client is unusually small (#{data.length} bytes)" assert.ok data.length < 50000, "Client is bloaty (#{data.length} bytes)" done() # # Testing channel tests # # The first thing a client does when it connects is issue a GET on /test/?mode=INIT. # The server responds with an array of [basePrefix or null,blockedPrefix or null]. Blocked # prefix isn't supported by node-browerchannel and by default no basePrefix is set. So with no # options specified, this GET should return [null,null]. test 'GET /test/?mode=INIT with no baseprefix set returns [null, null]', (done) -> @get '/channel/test?VER=8&MODE=init', (response) -> assert.strictEqual response.statusCode, 200 buffer response, (data) -> assert.strictEqual data, '[null,null]' done() # If a basePrefix is set in the options, make sure the server returns it. test 'GET /test/?mode=INIT with a basePrefix set returns [basePrefix, null]', (done) -> # You can specify a bunch of host prefixes. If you do, the server will randomly pick between them. # I don't know if thats actually useful behaviour, but *shrug* # I should probably write a test to make sure all host prefixes will be chosen from time to time. createServer hostPrefixes:['chan'], (->), (server, port) -> http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) -> assert.strictEqual response.statusCode, 200 buffer response, (data) -> assert.strictEqual data, '["chan",null]' # I'm being slack here - the server might not close immediately. I could make done() # dependant on it, but I can't be bothered. server.close() done() # Setting a custom url endpoint to bind node-browserchannel to should make it respond at that url endpoint # only. test 'The test channel responds at a bound custom endpoint', (done) -> createServer base:'/foozit', (->), (server, port) -> http.get {path:'/foozit/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) -> assert.strictEqual response.statusCode, 200 buffer response, (data) -> assert.strictEqual data, '[null,null]' server.close() done() # Some people will miss out on the leading slash in the URL when they bind browserchannel to a custom # url. That should work too. test 'binding the server to a custom url without a leading slash works', (done) -> createServer base:'foozit', (->), (server, port) -> http.get {path:'/foozit/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) -> assert.strictEqual response.statusCode, 200 buffer response, (data) -> assert.strictEqual data, '[null,null]' server.close() done() # Its tempting to think that you need a trailing slash on your URL prefix as well. You don't, but that should # work too. test 'binding the server to a custom url with a trailing slash works', (done) -> # Some day, the copy+paste police are gonna get me. I don't feel *so* bad doing it for tests though, because # it helps readability. createServer base:'foozit/', (->), (server, port) -> http.get {path:'/foozit/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) -> assert.strictEqual response.statusCode, 200 buffer response, (data) -> assert.strictEqual data, '[null,null]' server.close() done() # You can control the CORS header ('Access-Control-Allow-Origin') using options.cors. test 'CORS header is not sent if its not set in the options', (done) -> @get '/channel/test?VER=8&MODE=init', (response) -> assert.strictEqual response.headers['access-control-allow-origin'], undefined assert.strictEqual response.headers['access-control-allow-credentials'], undefined response.socket.end() done() test 'CORS headers are sent during the initial phase if set in the options', (done) -> createServer cors:'foo.com', corsAllowCredentials:true, (->), (server, port) -> http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) -> assert.strictEqual response.headers['access-control-allow-origin'], 'foo.com' assert.strictEqual response.headers['access-control-allow-credentials'], 'true' buffer response server.close() done() test 'CORS headers can be set using a function', (done) -> createServer cors: (-> 'something'), (->), (server, port) -> http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) -> assert.strictEqual response.headers['access-control-allow-origin'], 'something' buffer response server.close() done() test 'CORS header is set on the backchannel response', (done) -> server = port = null sessionCreated = (session) -> # Make the backchannel flush as soon as its opened session.send "flush" req = http.get {path:"/channel/bind?VER=8&RID=rpc&SID=#{session.id}&AID=0&TYPE=xmlhttp&CI=0", host:'localhost', port:port}, (res) => assert.strictEqual res.headers['access-control-allow-origin'], 'foo.com' assert.strictEqual res.headers['access-control-allow-credentials'], 'true' req.abort() server.close() done() createServer cors:'foo.com', corsAllowCredentials:true, sessionCreated, (_server, _port) -> [server, port] = [_server, _port] req = http.request {method:'POST', path:'/channel/bind?VER=8&RID=1000&t=1', host:'localhost', port:port}, (res) => req.end 'count=0' # This test is just testing one of the error responses for the presence of # the CORS header. It doesn't test all of the ports, and doesn't test IE. # (I'm not actually sure if CORS headers are needed for IE stuff) suite 'CORS header is set in error responses', -> setup (callback) -> createServer cors:'foo.com', corsAllowCredentials:true, (->), (@corsServer, @corsPort) => callback() teardown -> @corsServer.close() testResponse = (done, req, res) -> assert.strictEqual res.statusCode, 400 assert.strictEqual res.headers['access-control-allow-origin'], 'foo.com' assert.strictEqual res.headers['access-control-allow-credentials'], 'true' buffer res, (data) -> assert.ok data.indexOf('Unknown SID') > 0 req.abort() done() test 'backChannel', (done) -> req = http.get {path:'/channel/bind?VER=8&RID=rpc&SID=madeup&AID=0&TYPE=xmlhttp&CI=0', host:'localhost', port:@corsPort}, (res) => testResponse done, req, res test 'forwardChannel', (done) -> req = http.request {method:'POST', path:'/channel/bind?VER=8&RID=1001&SID=junkyjunk&AID=0', host:'localhost', port:@corsPort}, (res) => testResponse done, req, res req.end 'count=0' #@post "/channel/bind?VER=8&RID=1001&SID=junkyjunk&AID=0", 'count=0', testResponse(done) test 'Additional headers can be specified in the options', (done) -> createServer headers:{'X-Foo':'bar'}, (->), (server, port) -> http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) -> assert.strictEqual response.headers['x-foo'], 'bar' server.close() done() # Interestingly, the CORS header isn't required for old IE (type=html) requests because they're loaded using # iframes anyway. (Though this should really be tested). # node-browserchannel is only responsible for URLs with the specified (or default) prefix. If a request # comes in for a URL outside of that path, it should be passed along to subsequent connect middleware. # # I've set up the createServer() method above to send 'Other middleware' if browserchannel passes # the response on to the next handler. test 'getting a url outside of the bound range gets passed to other middleware', (done) -> @get '/otherapp', (response) -> assert.strictEqual response.statusCode, 200 buffer response, (data) -> assert.strictEqual data, 'Other middleware' done() # I decided to make URLs inside the bound range return 404s directly. I can't guarantee that no future # version of node-browserchannel won't add more URLs in the zone, so its important that users don't decide # to start using arbitrary other URLs under channel/. # # That design decision makes it impossible to add a custom 404 page to /channel/FOO, but I don't think thats a # big deal. test 'getting a wacky url inside the bound range returns 404', (done) -> @get '/channel/doesnotexist', (response) -> assert.strictEqual response.statusCode, 404 response.socket.end() done() # For node-browserchannel, we also accept JSON in forward channel POST data. To tell the client that # this is supported, we add an `X-Accept: application/json; application/x-www-form-urlencoded` header # in phase 1 of the test API. test 'The server sends accept:JSON header during test phase 1', (done) -> @get '/channel/test?VER=8&MODE=init', (res) -> assert.strictEqual res.headers['x-accept'], 'application/json; application/x-www-form-urlencoded' res.socket.end() done() # All the standard headers should be sent along with X-Accept test 'The server sends standard headers during test phase 1', (done) -> @get '/channel/test?VER=8&MODE=init', (res) => assert.strictEqual res.headers[k.toLowerCase()].toLowerCase(), v.toLowerCase() for k,v of @standardHeaders res.socket.end() done() # ## Testing phase 2 # # I should really sort the above tests better. # # Testing phase 2 the client GETs /channel/test?VER=8&TYPE= [html / xmlhttp] &zx=558cz3evkwuu&t=1 [&DOMAIN=xxxx] # # The server sends '11111' <2 second break> '2'. If you use html encoding instead, the server sends the client # a webpage which calls: # # document.domain='mail.google.com'; # parent.m('11111'); # parent.m('2'); # parent.d(); suite 'Getting test phase 2 returns 11111 then 2', -> makeTest = (type, message1, message2) -> (done) -> @get "/channel/test?VER=8&TYPE=#{type}", (response) -> assert.strictEqual response.statusCode, 200 expect response, message1, -> # Its important to make sure that message 2 isn't sent too soon (<2 seconds). # We'll advance the server's clock forward by just under 2 seconds and then wait a little bit # for messages from the client. If we get a message during this time, throw an error. response.on 'data', f = -> throw new Error 'should not get more data so early' timer.wait 1999, -> soon -> response.removeListener 'data', f expect response, message2, -> response.once 'end', -> done() timer.wait 1 test 'xmlhttp', makeTest 'xmlhttp', '11111', '2' # I could write this test using JSDom or something like that, and parse out the HTML correctly. # ... but it would be way more complicated (and no more correct) than simply comparing the resulting # strings. test 'html', makeTest('html', # These HTML responses are identical to what I'm getting from google's servers. I think the seemingly # random sequence is just so network framing doesn't try and chunk up the first packet sent to the server # or something like that. """<html><body><script>try {parent.m("11111")} catch(e) {}</script>\n#{ieJunk}""", '''<script>try {parent.m("2")} catch(e) {}</script> <script>try {parent.d(); }catch (e){}</script>\n''') # If a client is connecting with a host prefix, the server sets the iframe's document.domain to match # before sending actual data. 'html with a host prefix': makeTest('html&DOMAIN=foo.bar.com', # I've made a small change from google's implementation here. I'm using double quotes `"` instead of # single quotes `'` because its easier to encode. (I can't just wrap the string in quotes because there # are potential XSS vulnerabilities if I do that). """<html><body><script>try{document.domain="foo.bar.com";}catch(e){}</script> <script>try {parent.m("11111")} catch(e) {}</script>\n#{ieJunk}""", '''<script>try {parent.m("2")} catch(e) {}</script> <script>try {parent.d(); }catch (e){}</script>\n''') # IE doesn't parse the HTML in the response unless the Content-Type is text/html test 'Using type=html sets Content-Type: text/html', (done) -> r = @get "/channel/test?VER=8&TYPE=html", (response) -> assert.strictEqual response.headers['content-type'], 'text/html' r.abort() done() # IE should also get the standard headers test 'Using type=html gets the standard headers', (done) -> r = @get "/channel/test?VER=8&TYPE=html", (response) => for k, v of @standardHeaders when k isnt 'Content-Type' assert.strictEqual response.headers[k.toLowerCase()].toLowerCase(), v.toLowerCase() r.abort() done() # node-browserchannel is only compatible with browserchannel client version 8. I don't know whats changed # since old versions (maybe v6 would be easy to support) but I don't care. If the client specifies # an old version, we'll die with an error. # The alternate phase 2 URL style should have the same behaviour if the version is old or unspecified. # # Google's browserchannel server still works if you miss out on specifying the version - it defaults # to version 1 (which maybe didn't have version numbers in the URLs). I'm kind of impressed that # all that code still works. suite 'Getting /test/* without VER=8 returns an error', -> # All these tests look 95% the same. Instead of writing the same test all those times, I'll use this # little helper method to generate them. check400 = (path) -> (done) -> @get path, (response) -> assert.strictEqual response.statusCode, 400 response.socket.end() done() test 'phase 1, ver 7', check400 '/channel/test?VER=7&MODE=init' test 'phase 1, no version', check400 '/channel/test?MODE=init' test 'phase 2, ver 7, xmlhttp', check400 '/channel/test?VER=7&TYPE=xmlhttp' test 'phase 2, no version, xmlhttp', check400 '/channel/test?TYPE=xmlhttp' # For HTTP connections (IE), the error is sent a different way. Its kinda complicated how the error # is sent back, so for now I'm just going to ignore checking it. test 'phase 2, ver 7, http', check400 '/channel/test?VER=7&TYPE=html' test 'phase 2, no version, http', check400 '/channel/test?TYPE=html' # > At the moment the server expects the client will add a zx=###### query parameter to all requests. # The server isn't strict about this, so I'll ignore it in the tests for now. # # Server connection tests # These tests make server sessions by crafting raw HTTP queries. I'll make another set of # tests later which spam the server with a million fake clients. # # To start with, simply connect to a server using the BIND API. A client sends a server a few parameters: # # - **CVER**: Client application version # - **RID**: Client-side generated random number, which is the initial sequence number for the # client's requests. # - **VER**: Browserchannel protocol version. Must be 8. # - **t**: The connection attempt number. This is currently ignored by the BC server. (I'm not sure # what google's implementation does with this). test 'The server makes a new session if the client POSTs the right connection stuff', (done) -> id = null onSessionCalled = no # When a request comes in, we should get the new session through the server API. # # We need this session in order to find out the session ID, which should match up with part of the # server's response. @onSession = (session) -> assert.ok session assert.strictEqual typeof session.id, 'string' assert.strictEqual session.state, 'init' assert.strictEqual session.appVersion, '99' assert.deepEqual session.address, '127.0.0.1' assert.strictEqual typeof session.headers, 'object' id = session.id session.on 'map', -> throw new Error 'Should not have received data' onSessionCalled = yes # The client starts a BC connection by POSTing to /bind? with no session ID specified. # The client can optionally send data here, but in this case it won't (hence the `count=0`). @post '/channel/bind?VER=8&RID=1000&CVER=99&t=1&junk=asdfasdf', 'count=0', (res) => expected = (JSON.stringify [[0, ['c', id, null, 8]]]) + '\n' buffer res, (data) -> # Even for old IE clients, the server responds in length-prefixed JSON style. assert.strictEqual data, "#{expected.length}\n#{expected}" assert.ok onSessionCalled done() # Once a client's session id is sent, the session moves to the `ok` state. This happens after onSession is # called (so onSession can send messages to the client immediately). # # I'm starting to use the @connect method here, which just POSTs locally to create a session, sets @session and # calls its callback. test 'A session has state=ok after onSession returns', (done) -> @connect -> @session.on 'state changed', (newState, oldState) => assert.strictEqual oldState, 'init' assert.strictEqual newState, 'ok' assert.strictEqual @session.state, 'ok' done() # The CVER= property is optional during client connections. If its left out, session.appVersion is # null. test 'A session connects ok even if it doesnt specify an app version', (done) -> id = null onSessionCalled = no @onSession = (session) -> assert.strictEqual session.appVersion, null id = session.id session.on 'map', -> throw new Error 'Should not have received data' onSessionCalled = yes @post '/channel/bind?VER=8&RID=1000&t=1&junk=asdfasdf', 'count=0', (res) => expected = (JSON.stringify [[0, ['c', id, null, 8]]]) + '\n' buffer res, (data) -> assert.strictEqual data, "#{expected.length}\n#{expected}" assert.ok onSessionCalled done() # Once again, only VER=8 works. suite 'Connecting with a version thats not 8 breaks', -> # This will POST to the requested path and make sure the response sets status 400 check400 = (path) -> (done) -> @post path, 'count=0', (response) -> assert.strictEqual response.statusCode, 400 response.socket.end() done() test 'no version', check400 '/channel/bind?RID=1000&t=1' test 'previous version', check400 '/channel/bind?VER=7&RID=1000&t=1' # This time, we'll send a map to the server during the initial handshake. This should be received # by the server as normal. test 'The client can post messages to the server during initialization', (done) -> @onSession = (session) -> session.on 'map', (data) -> assert.deepEqual data, {k:'v'} done() @post '/channel/bind?VER=8&RID=1000&t=1', 'count=1&ofs=0&req0_k=v', (res) => res.socket.end() # The data received by the server should be properly URL decoded and whatnot. test 'Server messages are properly URL decoded', (done) -> @onSession = (session) -> session.on 'map', (data) -> assert.deepEqual data, {"_int_^&^%#net":'hi"there&&\nsam'} done() @post('/channel/bind?VER=8&RID=1000&t=1', 'count=1&ofs=0&req0__int_%5E%26%5E%25%23net=hi%22there%26%26%0Asam', (res) -> res.socket.end()) # After a client connects, it can POST data to the server using URL-encoded POST data. This data # is sent by POSTing to /bind?SID=.... # # The data looks like this: # # count=5&ofs=1000&req0_KEY1=VAL1&req0_KEY2=VAL2&req1_KEY3=req1_VAL3&... test 'The client can post messages to the server after initialization', (done) -> @connect -> @session.on 'map', (data) -> assert.deepEqual data, {k:'v'} done() @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_k=v', (res) => res.socket.end() # When the server gets a forwardchannel request, it should reply with a little array saying whats # going on. test 'The server acknowledges forward channel messages correctly', (done) -> @connect -> @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_k=v', (res) => readLengthPrefixedJSON res, (data) => # The server responds with [backchannelMissing ? 0 : 1, lastSentAID, outstandingBytes] assert.deepEqual data, [0, 0, 0] done() # If the server has an active backchannel, it responds to forward channel requests notifying the client # that the backchannel connection is alive and well. test 'The server tells the client if the backchannel is alive', (done) -> @connect -> # This will fire up a backchannel connection to the server. req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp", (res) => # The client shouldn't get any data through the backchannel. res.on 'data', -> throw new Error 'Should not get data through backchannel' # Unfortunately, the GET request is sent *after* the POST, so we have to wrap the # post in a timeout to make sure it hits the server after the backchannel connection is # established. soon => @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_k=v', (res) => readLengthPrefixedJSON res, (data) => # This time, we get a 1 as the first argument because the backchannel connection is # established. assert.deepEqual data, [1, 0, 0] # The backchannel hasn't gotten any data yet. It'll spend 15 seconds or so timing out # if we don't abort it manually. # As of nodejs 0.6, if you abort() a connection, it can emit an error. req.on 'error', -> req.abort() done() # The forward channel response tells the client how many unacknowledged bytes there are, so it can decide # whether or not it thinks the backchannel is dead. test 'The server tells the client how much unacknowledged data there is in the post response', (done) -> @connect -> process.nextTick => # I'm going to send a few messages to the client and acknowledge the first one in a post response. @session.send 'message 1' @session.send 'message 2' @session.send 'message 3' # We'll make a backchannel and get the data req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) => readLengthPrefixedJSON res, (data) => # After the data is received, I'll acknowledge the first message using an empty POST @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=1", 'count=0', (res) => readLengthPrefixedJSON res, (data) => # We should get a response saying "The backchannel is connected", "The last message I sent was 3" # "messages 2 and 3 haven't been acknowledged, here's their size" assert.deepEqual data, [1, 3, 25] req.abort() done() # When the user calls send(), data is queued by the server and sent into the next backchannel connection. # # The server will use the initial establishing connection if thats available, or it'll send it the next # time the client opens a backchannel connection. test 'The server returns data on the initial connection when send is called immediately', (done) -> testData = ['hello', 'there', null, 1000, {}, [], [555]] @onSession = (@session) => @session.send testData # I'm not using @connect because we need to know about the response to the first POST. @post '/channel/bind?VER=8&RID=1000&t=1', 'count=0', (res) => readLengthPrefixedJSON res, (data) => assert.deepEqual data, [[0, ['c', @session.id, null, 8]], [1, testData]] done() test 'The server escapes tricky characters before sending JSON over the wire', (done) -> testData = {'a': 'hello\u2028\u2029there\u2028\u2029'} @onSession = (@session) => @session.send testData # I'm not using @connect because we need to know about the response to the first POST. @post '/channel/bind?VER=8&RID=1000&t=1', 'count=0', (res) => readLengthPrefixedString res, (data) => assert.deepEqual data, """[[0,["c","#{@session.id}",null,8]],[1,{"a":"hello\\u2028\\u2029there\\u2028\\u2029"}]]\n""" done() test 'The server buffers data if no backchannel is available', (done) -> @connect -> testData = ['hello', 'there', null, 1000, {}, [], [555]] # The first response to the server is sent after this method returns, so if we send the data # in process.nextTick, it'll get buffered. process.nextTick => @session.send testData req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) => readLengthPrefixedJSON res, (data) => assert.deepEqual data, [[1, testData]] req.abort() done() # This time, we'll fire up the back channel first (and give it time to get established) _then_ # send data through the session. test 'The server returns data through the available backchannel when send is called later', (done) -> @connect -> testData = ['hello', 'there', null, 1000, {}, [], [555]] # Fire off the backchannel request as soon as the client has connected req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) -> #res.on 'data', (chunk) -> console.warn chunk.toString() readLengthPrefixedJSON res, (data) -> assert.deepEqual data, [[1, testData]] req.abort() done() # Send the data outside of the get block to make sure it makes it through. soon => @session.send testData # The server should call the send callback once the data has been confirmed by the client. # # We'll try sending three messages to the client. The first message will be sent during init and the # third message will not be acknowledged. Only the first two message callbacks should get called. test 'The server calls send callback once data is acknowledged', (done) -> @connect -> lastAck = null @session.send [1], -> assert.strictEqual lastAck, null lastAck = 1 process.nextTick => @session.send [2], -> assert.strictEqual lastAck, 1 # I want to give the test time to die soon -> done() # This callback should actually get called with an error after the client times out. ... but I'm not # giving timeouts a chance to run. @session.send [3], -> throw new Error 'Should not call unacknowledged send callback' req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res) => readLengthPrefixedJSON res, (data) => assert.deepEqual data, [[2, [2]], [3, [3]]] # Ok, now we'll only acknowledge the second message by sending AID=2 @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=2", 'count=0', (res) => res.socket.end() req.abort() # This tests for a regression - if the stop callback closed the connection, the server was crashing. test 'The send callback can stop the session', (done) -> @connect -> req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res) => # Acknowledge the stop message @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=2", 'count=0', (res) => res.socket.end() @session.stop => @session.close() soon -> req.abort() done() # If there's a proxy in the way which chunks up responses before sending them on, the client adds a # &CI=1 argument on the backchannel. This causes the server to end the HTTP query after each message # is sent, so the data is sent to the session. test 'The backchannel is closed after each packet if chunking is turned off', (done) -> @connect -> testData = ['hello', 'there', null, 1000, {}, [], [555]] process.nextTick => @session.send testData # Instead of the usual CI=0 we're passing CI=1 here. @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=1", (res) => readLengthPrefixedJSON res, (data) => assert.deepEqual data, [[1, testData]] res.on 'end', -> done() # Normally, the server doesn't close the connection after each backchannel message. test 'The backchannel is left open if CI=0', (done) -> @connect -> testData = ['hello', 'there', null, 1000, {}, [], [555]] process.nextTick => @session.send testData req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) => readLengthPrefixedJSON res, (data) => assert.deepEqual data, [[1, testData]] # After receiving the data, the client shouldn't close the connection. (At least, not unless # it times out naturally). res.on 'end', -> throw new Error 'connection should have stayed open' soon -> res.removeAllListeners 'end' req.abort() done() # On IE, the data is all loaded using iframes. The backchannel spits out data using inline scripts # in an HTML page. # # I've written this test separately from the tests above, but it would really make more sense # to rerun the same set of tests in both HTML and XHR modes to make sure the behaviour is correct # in both instances. test 'The server gives the client correctly formatted backchannel data if TYPE=html', (done) -> @connect -> testData = ['hello', 'there', null, 1000, {}, [], [555]] process.nextTick => @session.send testData # The type is specified as an argument here in the query string. For this test, I'm making # CI=1, because the test is easier to write that way. # # In truth, I don't care about IE support as much as support for modern browsers. This might # be a mistake.. I'm not sure. IE9's XHR support should work just fine for browserchannel, # though google's BC client doesn't use it. @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=html&CI=1", (res) => expect res, # Interestingly, google doesn't double-encode the string like this. Instead of turning # quotes `"` into escaped quotes `\"`, it uses unicode encoding to turn them into \42 and # stuff like that. I'm not sure why they do this - it produces the same effect in IE8. # I should test it in IE6 and see if there's any problems. """<html><body><script>try {parent.m(#{JSON.stringify JSON.stringify([[1, testData]]) + '\n'})} catch(e) {}</script> #{ieJunk}<script>try {parent.d(); }catch (e){}</script>\n""", => # Because I'm lazy, I'm going to chain on a test to make sure CI=0 works as well. data2 = {other:'data'} @session.send data2 # I'm setting AID=1 here to indicate that the client has seen array 1. req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=html&CI=0", (res) => expect res, """<html><body><script>try {parent.m(#{JSON.stringify JSON.stringify([[2, data2]]) + '\n'})} catch(e) {}</script> #{ieJunk}""", => req.abort() done() # If there's a basePrefix set, the returned HTML sets `document.domain = ` before sending messages. # I'm super lazy, and just copy+pasting from the test above. There's probably a way to factor these tests # nicely, but I'm not in the mood to figure it out at the moment. test 'The server sets the domain if we have a domain set', (done) -> @connect -> testData = ['hello', 'there', null, 1000, {}, [], [555]] process.nextTick => @session.send testData # This time we're setting DOMAIN=X, and the response contains a document.domain= block. Woo. @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=html&CI=1&DOMAIN=foo.com", (res) => expect res, """<html><body><script>try{document.domain=\"foo.com\";}catch(e){}</script> <script>try {parent.m(#{JSON.stringify JSON.stringify([[1, testData]]) + '\n'})} catch(e) {}</script> #{ieJunk}<script>try {parent.d(); }catch (e){}</script>\n""", => data2 = {other:'data'} @session.send data2 req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=html&CI=0&DOMAIN=foo.com", (res) => expect res, # Its interesting - in the test channel, the ie junk comes right after the document.domain= line, # but in a backchannel like this it comes after. The behaviour here is the same in google's version. # # I'm not sure if its actually significant though. """<html><body><script>try{document.domain=\"foo.com\";}catch(e){}</script> <script>try {parent.m(#{JSON.stringify JSON.stringify([[2, data2]]) + '\n'})} catch(e) {}</script> #{ieJunk}""", => req.abort() done() # If a client thinks their backchannel connection is closed, they might open a second backchannel connection. # In this case, the server should close the old one and resume sending stuff using the new connection. test 'The server closes old backchannel connections', (done) -> @connect -> testData = ['hello', 'there', null, 1000, {}, [], [555]] process.nextTick => @session.send testData # As usual, we'll get the sent data through the backchannel connection. The connection is kept open... @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) => readLengthPrefixedJSON res, (data) => # ... and the data has been read. Now we'll open another connection and check that the first connection # gets closed. req2 = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res2) => res.on 'end', -> req2.on 'error', -> req2.abort() done() # The client attaches a sequence number (*RID*) to every message, to make sure they don't end up out-of-order at # the server's end. # # We'll purposefully send some messages out of order and make sure they're held and passed through in order. # # Gogo gadget reimplementing TCP. test 'The server orders forwardchannel messages correctly using RIDs', (done) -> @connect -> # @connect sets RID=1000. # We'll send 2 maps, the first one will be {v:1} then {v:0}. They should be swapped around by the server. lastVal = 0 @session.on 'map', (map) -> assert.strictEqual map.v, "#{lastVal++}", 'messages arent reordered in the server' done() if map.v == '2' # First, send `[{v:2}]` @post "/channel/bind?VER=8&RID=1002&SID=#{@session.id}&AID=0", 'count=1&ofs=2&req0_v=2', (res) => res.socket.end() # ... then `[{v:0}, {v:1}]` a few MS later. soon => @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=2&ofs=0&req0_v=0&req1_v=1', (res) => res.socket.end() # Again, think of browserchannel as TCP on top of UDP... test 'Repeated forward channel messages are discarded', (done) -> @connect -> gotMessage = false # The map must only be received once. @session.on 'map', (map) -> if gotMessage == false gotMessage = true else throw new Error 'got map twice' # POST the maps twice. @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_v=0', (res) => res.socket.end() @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_v=0', (res) => res.socket.end() # Wait 50 milliseconds for the map to (maybe!) be received twice, then pass. soon -> assert.strictEqual gotMessage, true done() # The client can retry failed forwardchannel requests with additional maps. We may have gotten the failed # request. An error could have occurred when we reply. test 'Repeat forward channel messages can contain extra maps', (done) -> @connect -> # We should get exactly 2 maps, {v:0} then {v:1} maps = [] @session.on 'map', (map) -> maps.push map @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_v=0', (res) => res.socket.end() @post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=2&ofs=0&req0_v=0&req1_v=1', (res) => res.socket.end() soon -> assert.deepEqual maps, [{v:0}, {v:1}] done() # With each request to the server, the client tells the server what array it saw last through the AID= parameter. # # If a client sends a subsequent backchannel request with an old AID= set, that means the client never saw the arrays # the server has previously sent. So, the server should resend them. test 'The server resends lost arrays if the client asks for them', (done) -> @connect -> process.nextTick => @session.send [1,2,3] @session.send [4,5,6] @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) => readLengthPrefixedJSON res, (data) => assert.deepEqual data, [[1, [1,2,3]], [2, [4,5,6]]] # We'll resend that request, pretending that the client never saw the second array sent (`[4,5,6]`) req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res) => readLengthPrefixedJSON res, (data) => assert.deepEqual data, [[2, [4,5,6]]] # We don't need to abort the first connection because the server should close it. req.abort() done() # If you sleep your laptop or something, by the time you open it again the server could have timed out your session # so your session ID is invalid. This will also happen if the server gets restarted or something like that. # # The server should respond to any query requesting a nonexistant session ID with 400 and put 'Unknown SID' # somewhere in the message. (Actually, the BC client test is `indexOf('Unknown SID') > 0` so there has to be something # before that text in the message or indexOf will return 0. # # The google servers also set Unknown SID as the http status code, which is kinda neat. I can't check for that. suite 'If a client sends an invalid SID in a request, the server responds with 400 Unknown SID', -> testResponse = (done) -> (res) -> assert.strictEqual res.statusCode, 400 buffer res, (data) -> assert.ok data.indexOf('U