1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/services/sync/tests/unit/head_http_server.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,1001 @@ 1.4 +const Cm = Components.manager; 1.5 + 1.6 +// Shared logging for all HTTP server functions. 1.7 +Cu.import("resource://gre/modules/Log.jsm"); 1.8 +const SYNC_HTTP_LOGGER = "Sync.Test.Server"; 1.9 +const SYNC_API_VERSION = "1.1"; 1.10 + 1.11 +// Use the same method that record.js does, which mirrors the server. 1.12 +// The server returns timestamps with 1/100 sec granularity. Note that this is 1.13 +// subject to change: see Bug 650435. 1.14 +function new_timestamp() { 1.15 + return Math.round(Date.now() / 10) / 100; 1.16 +} 1.17 + 1.18 +function return_timestamp(request, response, timestamp) { 1.19 + if (!timestamp) { 1.20 + timestamp = new_timestamp(); 1.21 + } 1.22 + let body = "" + timestamp; 1.23 + response.setHeader("X-Weave-Timestamp", body); 1.24 + response.setStatusLine(request.httpVersion, 200, "OK"); 1.25 + response.bodyOutputStream.write(body, body.length); 1.26 + return timestamp; 1.27 +} 1.28 + 1.29 +function basic_auth_header(user, password) { 1.30 + return "Basic " + btoa(user + ":" + Utils.encodeUTF8(password)); 1.31 +} 1.32 + 1.33 +function basic_auth_matches(req, user, password) { 1.34 + if (!req.hasHeader("Authorization")) { 1.35 + return false; 1.36 + } 1.37 + 1.38 + let expected = basic_auth_header(user, Utils.encodeUTF8(password)); 1.39 + return req.getHeader("Authorization") == expected; 1.40 +} 1.41 + 1.42 +function httpd_basic_auth_handler(body, metadata, response) { 1.43 + if (basic_auth_matches(metadata, "guest", "guest")) { 1.44 + response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); 1.45 + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); 1.46 + } else { 1.47 + body = "This path exists and is protected - failed"; 1.48 + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); 1.49 + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); 1.50 + } 1.51 + response.bodyOutputStream.write(body, body.length); 1.52 +} 1.53 + 1.54 +/* 1.55 + * Represent a WBO on the server 1.56 + */ 1.57 +function ServerWBO(id, initialPayload, modified) { 1.58 + if (!id) { 1.59 + throw "No ID for ServerWBO!"; 1.60 + } 1.61 + this.id = id; 1.62 + if (!initialPayload) { 1.63 + return; 1.64 + } 1.65 + 1.66 + if (typeof initialPayload == "object") { 1.67 + initialPayload = JSON.stringify(initialPayload); 1.68 + } 1.69 + this.payload = initialPayload; 1.70 + this.modified = modified || new_timestamp(); 1.71 +} 1.72 +ServerWBO.prototype = { 1.73 + 1.74 + get data() { 1.75 + return JSON.parse(this.payload); 1.76 + }, 1.77 + 1.78 + get: function() { 1.79 + return JSON.stringify(this, ["id", "modified", "payload"]); 1.80 + }, 1.81 + 1.82 + put: function(input) { 1.83 + input = JSON.parse(input); 1.84 + this.payload = input.payload; 1.85 + this.modified = new_timestamp(); 1.86 + }, 1.87 + 1.88 + delete: function() { 1.89 + delete this.payload; 1.90 + delete this.modified; 1.91 + }, 1.92 + 1.93 + // This handler sets `newModified` on the response body if the collection 1.94 + // timestamp has changed. This allows wrapper handlers to extract information 1.95 + // that otherwise would exist only in the body stream. 1.96 + handler: function() { 1.97 + let self = this; 1.98 + 1.99 + return function(request, response) { 1.100 + var statusCode = 200; 1.101 + var status = "OK"; 1.102 + var body; 1.103 + 1.104 + switch(request.method) { 1.105 + case "GET": 1.106 + if (self.payload) { 1.107 + body = self.get(); 1.108 + } else { 1.109 + statusCode = 404; 1.110 + status = "Not Found"; 1.111 + body = "Not Found"; 1.112 + } 1.113 + break; 1.114 + 1.115 + case "PUT": 1.116 + self.put(readBytesFromInputStream(request.bodyInputStream)); 1.117 + body = JSON.stringify(self.modified); 1.118 + response.setHeader("Content-Type", "application/json"); 1.119 + response.newModified = self.modified; 1.120 + break; 1.121 + 1.122 + case "DELETE": 1.123 + self.delete(); 1.124 + let ts = new_timestamp(); 1.125 + body = JSON.stringify(ts); 1.126 + response.setHeader("Content-Type", "application/json"); 1.127 + response.newModified = ts; 1.128 + break; 1.129 + } 1.130 + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); 1.131 + response.setStatusLine(request.httpVersion, statusCode, status); 1.132 + response.bodyOutputStream.write(body, body.length); 1.133 + }; 1.134 + } 1.135 + 1.136 +}; 1.137 + 1.138 + 1.139 +/** 1.140 + * Represent a collection on the server. The '_wbos' attribute is a 1.141 + * mapping of id -> ServerWBO objects. 1.142 + * 1.143 + * Note that if you want these records to be accessible individually, 1.144 + * you need to register their handlers with the server separately, or use a 1.145 + * containing HTTP server that will do so on your behalf. 1.146 + * 1.147 + * @param wbos 1.148 + * An object mapping WBO IDs to ServerWBOs. 1.149 + * @param acceptNew 1.150 + * If true, POSTs to this collection URI will result in new WBOs being 1.151 + * created and wired in on the fly. 1.152 + * @param timestamp 1.153 + * An optional timestamp value to initialize the modified time of the 1.154 + * collection. This should be in the format returned by new_timestamp(). 1.155 + * 1.156 + * @return the new ServerCollection instance. 1.157 + * 1.158 + */ 1.159 +function ServerCollection(wbos, acceptNew, timestamp) { 1.160 + this._wbos = wbos || {}; 1.161 + this.acceptNew = acceptNew || false; 1.162 + 1.163 + /* 1.164 + * Track modified timestamp. 1.165 + * We can't just use the timestamps of contained WBOs: an empty collection 1.166 + * has a modified time. 1.167 + */ 1.168 + this.timestamp = timestamp || new_timestamp(); 1.169 + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); 1.170 +} 1.171 +ServerCollection.prototype = { 1.172 + 1.173 + /** 1.174 + * Convenience accessor for our WBO keys. 1.175 + * Excludes deleted items, of course. 1.176 + * 1.177 + * @param filter 1.178 + * A predicate function (applied to the ID and WBO) which dictates 1.179 + * whether to include the WBO's ID in the output. 1.180 + * 1.181 + * @return an array of IDs. 1.182 + */ 1.183 + keys: function keys(filter) { 1.184 + return [id for ([id, wbo] in Iterator(this._wbos)) 1.185 + if (wbo.payload && 1.186 + (!filter || filter(id, wbo)))]; 1.187 + }, 1.188 + 1.189 + /** 1.190 + * Convenience method to get an array of WBOs. 1.191 + * Optionally provide a filter function. 1.192 + * 1.193 + * @param filter 1.194 + * A predicate function, applied to the WBO, which dictates whether to 1.195 + * include the WBO in the output. 1.196 + * 1.197 + * @return an array of ServerWBOs. 1.198 + */ 1.199 + wbos: function wbos(filter) { 1.200 + let os = [wbo for ([id, wbo] in Iterator(this._wbos)) 1.201 + if (wbo.payload)]; 1.202 + if (filter) { 1.203 + return os.filter(filter); 1.204 + } 1.205 + return os; 1.206 + }, 1.207 + 1.208 + /** 1.209 + * Convenience method to get an array of parsed ciphertexts. 1.210 + * 1.211 + * @return an array of the payloads of each stored WBO. 1.212 + */ 1.213 + payloads: function () { 1.214 + return this.wbos().map(function (wbo) { 1.215 + return JSON.parse(JSON.parse(wbo.payload).ciphertext); 1.216 + }); 1.217 + }, 1.218 + 1.219 + // Just for syntactic elegance. 1.220 + wbo: function wbo(id) { 1.221 + return this._wbos[id]; 1.222 + }, 1.223 + 1.224 + payload: function payload(id) { 1.225 + return this.wbo(id).payload; 1.226 + }, 1.227 + 1.228 + /** 1.229 + * Insert the provided WBO under its ID. 1.230 + * 1.231 + * @return the provided WBO. 1.232 + */ 1.233 + insertWBO: function insertWBO(wbo) { 1.234 + return this._wbos[wbo.id] = wbo; 1.235 + }, 1.236 + 1.237 + /** 1.238 + * Insert the provided payload as part of a new ServerWBO with the provided 1.239 + * ID. 1.240 + * 1.241 + * @param id 1.242 + * The GUID for the WBO. 1.243 + * @param payload 1.244 + * The payload, as provided to the ServerWBO constructor. 1.245 + * @param modified 1.246 + * An optional modified time for the ServerWBO. 1.247 + * 1.248 + * @return the inserted WBO. 1.249 + */ 1.250 + insert: function insert(id, payload, modified) { 1.251 + return this.insertWBO(new ServerWBO(id, payload, modified)); 1.252 + }, 1.253 + 1.254 + /** 1.255 + * Removes an object entirely from the collection. 1.256 + * 1.257 + * @param id 1.258 + * (string) ID to remove. 1.259 + */ 1.260 + remove: function remove(id) { 1.261 + delete this._wbos[id]; 1.262 + }, 1.263 + 1.264 + _inResultSet: function(wbo, options) { 1.265 + return wbo.payload 1.266 + && (!options.ids || (options.ids.indexOf(wbo.id) != -1)) 1.267 + && (!options.newer || (wbo.modified > options.newer)); 1.268 + }, 1.269 + 1.270 + count: function(options) { 1.271 + options = options || {}; 1.272 + let c = 0; 1.273 + for (let [id, wbo] in Iterator(this._wbos)) { 1.274 + if (wbo.modified && this._inResultSet(wbo, options)) { 1.275 + c++; 1.276 + } 1.277 + } 1.278 + return c; 1.279 + }, 1.280 + 1.281 + get: function(options) { 1.282 + let result; 1.283 + if (options.full) { 1.284 + let data = [wbo.get() for ([id, wbo] in Iterator(this._wbos)) 1.285 + // Drop deleted. 1.286 + if (wbo.modified && 1.287 + this._inResultSet(wbo, options))]; 1.288 + if (options.limit) { 1.289 + data = data.slice(0, options.limit); 1.290 + } 1.291 + // Our implementation of application/newlines. 1.292 + result = data.join("\n") + "\n"; 1.293 + 1.294 + // Use options as a backchannel to report count. 1.295 + options.recordCount = data.length; 1.296 + } else { 1.297 + let data = [id for ([id, wbo] in Iterator(this._wbos)) 1.298 + if (this._inResultSet(wbo, options))]; 1.299 + if (options.limit) { 1.300 + data = data.slice(0, options.limit); 1.301 + } 1.302 + result = JSON.stringify(data); 1.303 + options.recordCount = data.length; 1.304 + } 1.305 + return result; 1.306 + }, 1.307 + 1.308 + post: function(input) { 1.309 + input = JSON.parse(input); 1.310 + let success = []; 1.311 + let failed = {}; 1.312 + 1.313 + // This will count records where we have an existing ServerWBO 1.314 + // registered with us as successful and all other records as failed. 1.315 + for each (let record in input) { 1.316 + let wbo = this.wbo(record.id); 1.317 + if (!wbo && this.acceptNew) { 1.318 + this._log.debug("Creating WBO " + JSON.stringify(record.id) + 1.319 + " on the fly."); 1.320 + wbo = new ServerWBO(record.id); 1.321 + this.insertWBO(wbo); 1.322 + } 1.323 + if (wbo) { 1.324 + wbo.payload = record.payload; 1.325 + wbo.modified = new_timestamp(); 1.326 + success.push(record.id); 1.327 + } else { 1.328 + failed[record.id] = "no wbo configured"; 1.329 + } 1.330 + } 1.331 + return {modified: new_timestamp(), 1.332 + success: success, 1.333 + failed: failed}; 1.334 + }, 1.335 + 1.336 + delete: function(options) { 1.337 + let deleted = []; 1.338 + for (let [id, wbo] in Iterator(this._wbos)) { 1.339 + if (this._inResultSet(wbo, options)) { 1.340 + this._log.debug("Deleting " + JSON.stringify(wbo)); 1.341 + deleted.push(wbo.id); 1.342 + wbo.delete(); 1.343 + } 1.344 + } 1.345 + return deleted; 1.346 + }, 1.347 + 1.348 + // This handler sets `newModified` on the response body if the collection 1.349 + // timestamp has changed. 1.350 + handler: function() { 1.351 + let self = this; 1.352 + 1.353 + return function(request, response) { 1.354 + var statusCode = 200; 1.355 + var status = "OK"; 1.356 + var body; 1.357 + 1.358 + // Parse queryString 1.359 + let options = {}; 1.360 + for each (let chunk in request.queryString.split("&")) { 1.361 + if (!chunk) { 1.362 + continue; 1.363 + } 1.364 + chunk = chunk.split("="); 1.365 + if (chunk.length == 1) { 1.366 + options[chunk[0]] = ""; 1.367 + } else { 1.368 + options[chunk[0]] = chunk[1]; 1.369 + } 1.370 + } 1.371 + if (options.ids) { 1.372 + options.ids = options.ids.split(","); 1.373 + } 1.374 + if (options.newer) { 1.375 + options.newer = parseFloat(options.newer); 1.376 + } 1.377 + if (options.limit) { 1.378 + options.limit = parseInt(options.limit, 10); 1.379 + } 1.380 + 1.381 + switch(request.method) { 1.382 + case "GET": 1.383 + body = self.get(options); 1.384 + // "If supported by the db, this header will return the number of 1.385 + // records total in the request body of any multiple-record GET 1.386 + // request." 1.387 + let records = options.recordCount; 1.388 + self._log.info("Records: " + records); 1.389 + if (records != null) { 1.390 + response.setHeader("X-Weave-Records", "" + records); 1.391 + } 1.392 + break; 1.393 + 1.394 + case "POST": 1.395 + let res = self.post(readBytesFromInputStream(request.bodyInputStream)); 1.396 + body = JSON.stringify(res); 1.397 + response.newModified = res.modified; 1.398 + break; 1.399 + 1.400 + case "DELETE": 1.401 + self._log.debug("Invoking ServerCollection.DELETE."); 1.402 + let deleted = self.delete(options); 1.403 + let ts = new_timestamp(); 1.404 + body = JSON.stringify(ts); 1.405 + response.newModified = ts; 1.406 + response.deleted = deleted; 1.407 + break; 1.408 + } 1.409 + response.setHeader("X-Weave-Timestamp", 1.410 + "" + new_timestamp(), 1.411 + false); 1.412 + response.setStatusLine(request.httpVersion, statusCode, status); 1.413 + response.bodyOutputStream.write(body, body.length); 1.414 + 1.415 + // Update the collection timestamp to the appropriate modified time. 1.416 + // This is either a value set by the handler, or the current time. 1.417 + if (request.method != "GET") { 1.418 + this.timestamp = (response.newModified >= 0) ? 1.419 + response.newModified : 1.420 + new_timestamp(); 1.421 + } 1.422 + }; 1.423 + } 1.424 + 1.425 +}; 1.426 + 1.427 +/* 1.428 + * Test setup helpers. 1.429 + */ 1.430 +function sync_httpd_setup(handlers) { 1.431 + handlers["/1.1/foo/storage/meta/global"] 1.432 + = (new ServerWBO("global", {})).handler(); 1.433 + return httpd_setup(handlers); 1.434 +} 1.435 + 1.436 +/* 1.437 + * Track collection modified times. Return closures. 1.438 + */ 1.439 +function track_collections_helper() { 1.440 + 1.441 + /* 1.442 + * Our tracking object. 1.443 + */ 1.444 + let collections = {}; 1.445 + 1.446 + /* 1.447 + * Update the timestamp of a collection. 1.448 + */ 1.449 + function update_collection(coll, ts) { 1.450 + _("Updating collection " + coll + " to " + ts); 1.451 + let timestamp = ts || new_timestamp(); 1.452 + collections[coll] = timestamp; 1.453 + } 1.454 + 1.455 + /* 1.456 + * Invoke a handler, updating the collection's modified timestamp unless 1.457 + * it's a GET request. 1.458 + */ 1.459 + function with_updated_collection(coll, f) { 1.460 + return function(request, response) { 1.461 + f.call(this, request, response); 1.462 + 1.463 + // Update the collection timestamp to the appropriate modified time. 1.464 + // This is either a value set by the handler, or the current time. 1.465 + if (request.method != "GET") { 1.466 + update_collection(coll, response.newModified) 1.467 + } 1.468 + }; 1.469 + } 1.470 + 1.471 + /* 1.472 + * Return the info/collections object. 1.473 + */ 1.474 + function info_collections(request, response) { 1.475 + let body = "Error."; 1.476 + switch(request.method) { 1.477 + case "GET": 1.478 + body = JSON.stringify(collections); 1.479 + break; 1.480 + default: 1.481 + throw "Non-GET on info_collections."; 1.482 + } 1.483 + 1.484 + response.setHeader("Content-Type", "application/json"); 1.485 + response.setHeader("X-Weave-Timestamp", 1.486 + "" + new_timestamp(), 1.487 + false); 1.488 + response.setStatusLine(request.httpVersion, 200, "OK"); 1.489 + response.bodyOutputStream.write(body, body.length); 1.490 + } 1.491 + 1.492 + return {"collections": collections, 1.493 + "handler": info_collections, 1.494 + "with_updated_collection": with_updated_collection, 1.495 + "update_collection": update_collection}; 1.496 +} 1.497 + 1.498 +//===========================================================================// 1.499 +// httpd.js-based Sync server. // 1.500 +//===========================================================================// 1.501 + 1.502 +/** 1.503 + * In general, the preferred way of using SyncServer is to directly introspect 1.504 + * it. Callbacks are available for operations which are hard to verify through 1.505 + * introspection, such as deletions. 1.506 + * 1.507 + * One of the goals of this server is to provide enough hooks for test code to 1.508 + * find out what it needs without monkeypatching. Use this object as your 1.509 + * prototype, and override as appropriate. 1.510 + */ 1.511 +let SyncServerCallback = { 1.512 + onCollectionDeleted: function onCollectionDeleted(user, collection) {}, 1.513 + onItemDeleted: function onItemDeleted(user, collection, wboID) {}, 1.514 + 1.515 + /** 1.516 + * Called at the top of every request. 1.517 + * 1.518 + * Allows the test to inspect the request. Hooks should be careful not to 1.519 + * modify or change state of the request or they may impact future processing. 1.520 + */ 1.521 + onRequest: function onRequest(request) {}, 1.522 +}; 1.523 + 1.524 +/** 1.525 + * Construct a new test Sync server. Takes a callback object (e.g., 1.526 + * SyncServerCallback) as input. 1.527 + */ 1.528 +function SyncServer(callback) { 1.529 + this.callback = callback || {__proto__: SyncServerCallback}; 1.530 + this.server = new HttpServer(); 1.531 + this.started = false; 1.532 + this.users = {}; 1.533 + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); 1.534 + 1.535 + // Install our own default handler. This allows us to mess around with the 1.536 + // whole URL space. 1.537 + let handler = this.server._handler; 1.538 + handler._handleDefault = this.handleDefault.bind(this, handler); 1.539 +} 1.540 +SyncServer.prototype = { 1.541 + server: null, // HttpServer. 1.542 + users: null, // Map of username => {collections, password}. 1.543 + 1.544 + /** 1.545 + * Start the SyncServer's underlying HTTP server. 1.546 + * 1.547 + * @param port 1.548 + * The numeric port on which to start. A falsy value implies the 1.549 + * default, a randomly chosen port. 1.550 + * @param cb 1.551 + * A callback function (of no arguments) which is invoked after 1.552 + * startup. 1.553 + */ 1.554 + start: function start(port, cb) { 1.555 + if (this.started) { 1.556 + this._log.warn("Warning: server already started on " + this.port); 1.557 + return; 1.558 + } 1.559 + try { 1.560 + this.server.start(port); 1.561 + let i = this.server.identity; 1.562 + this.port = i.primaryPort; 1.563 + this.baseURI = i.primaryScheme + "://" + i.primaryHost + ":" + 1.564 + i.primaryPort + "/"; 1.565 + this.started = true; 1.566 + if (cb) { 1.567 + cb(); 1.568 + } 1.569 + } catch (ex) { 1.570 + _("=========================================="); 1.571 + _("Got exception starting Sync HTTP server."); 1.572 + _("Error: " + Utils.exceptionStr(ex)); 1.573 + _("Is there a process already listening on port " + port + "?"); 1.574 + _("=========================================="); 1.575 + do_throw(ex); 1.576 + } 1.577 + 1.578 + }, 1.579 + 1.580 + /** 1.581 + * Stop the SyncServer's HTTP server. 1.582 + * 1.583 + * @param cb 1.584 + * A callback function. Invoked after the server has been stopped. 1.585 + * 1.586 + */ 1.587 + stop: function stop(cb) { 1.588 + if (!this.started) { 1.589 + this._log.warn("SyncServer: Warning: server not running. Can't stop me now!"); 1.590 + return; 1.591 + } 1.592 + 1.593 + this.server.stop(cb); 1.594 + this.started = false; 1.595 + }, 1.596 + 1.597 + /** 1.598 + * Return a server timestamp for a record. 1.599 + * The server returns timestamps with 1/100 sec granularity. Note that this is 1.600 + * subject to change: see Bug 650435. 1.601 + */ 1.602 + timestamp: function timestamp() { 1.603 + return new_timestamp(); 1.604 + }, 1.605 + 1.606 + /** 1.607 + * Create a new user, complete with an empty set of collections. 1.608 + * 1.609 + * @param username 1.610 + * The username to use. An Error will be thrown if a user by that name 1.611 + * already exists. 1.612 + * @param password 1.613 + * A password string. 1.614 + * 1.615 + * @return a user object, as would be returned by server.user(username). 1.616 + */ 1.617 + registerUser: function registerUser(username, password) { 1.618 + if (username in this.users) { 1.619 + throw new Error("User already exists."); 1.620 + } 1.621 + this.users[username] = { 1.622 + password: password, 1.623 + collections: {} 1.624 + }; 1.625 + return this.user(username); 1.626 + }, 1.627 + 1.628 + userExists: function userExists(username) { 1.629 + return username in this.users; 1.630 + }, 1.631 + 1.632 + getCollection: function getCollection(username, collection) { 1.633 + return this.users[username].collections[collection]; 1.634 + }, 1.635 + 1.636 + _insertCollection: function _insertCollection(collections, collection, wbos) { 1.637 + let coll = new ServerCollection(wbos, true); 1.638 + coll.collectionHandler = coll.handler(); 1.639 + collections[collection] = coll; 1.640 + return coll; 1.641 + }, 1.642 + 1.643 + createCollection: function createCollection(username, collection, wbos) { 1.644 + if (!(username in this.users)) { 1.645 + throw new Error("Unknown user."); 1.646 + } 1.647 + let collections = this.users[username].collections; 1.648 + if (collection in collections) { 1.649 + throw new Error("Collection already exists."); 1.650 + } 1.651 + return this._insertCollection(collections, collection, wbos); 1.652 + }, 1.653 + 1.654 + /** 1.655 + * Accept a map like the following: 1.656 + * { 1.657 + * meta: {global: {version: 1, ...}}, 1.658 + * crypto: {"keys": {}, foo: {bar: 2}}, 1.659 + * bookmarks: {} 1.660 + * } 1.661 + * to cause collections and WBOs to be created. 1.662 + * If a collection already exists, no error is raised. 1.663 + * If a WBO already exists, it will be updated to the new contents. 1.664 + */ 1.665 + createContents: function createContents(username, collections) { 1.666 + if (!(username in this.users)) { 1.667 + throw new Error("Unknown user."); 1.668 + } 1.669 + let userCollections = this.users[username].collections; 1.670 + for (let [id, contents] in Iterator(collections)) { 1.671 + let coll = userCollections[id] || 1.672 + this._insertCollection(userCollections, id); 1.673 + for (let [wboID, payload] in Iterator(contents)) { 1.674 + coll.insert(wboID, payload); 1.675 + } 1.676 + } 1.677 + }, 1.678 + 1.679 + /** 1.680 + * Insert a WBO in an existing collection. 1.681 + */ 1.682 + insertWBO: function insertWBO(username, collection, wbo) { 1.683 + if (!(username in this.users)) { 1.684 + throw new Error("Unknown user."); 1.685 + } 1.686 + let userCollections = this.users[username].collections; 1.687 + if (!(collection in userCollections)) { 1.688 + throw new Error("Unknown collection."); 1.689 + } 1.690 + userCollections[collection].insertWBO(wbo); 1.691 + return wbo; 1.692 + }, 1.693 + 1.694 + /** 1.695 + * Delete all of the collections for the named user. 1.696 + * 1.697 + * @param username 1.698 + * The name of the affected user. 1.699 + * 1.700 + * @return a timestamp. 1.701 + */ 1.702 + deleteCollections: function deleteCollections(username) { 1.703 + if (!(username in this.users)) { 1.704 + throw new Error("Unknown user."); 1.705 + } 1.706 + let userCollections = this.users[username].collections; 1.707 + for each (let [name, coll] in Iterator(userCollections)) { 1.708 + this._log.trace("Bulk deleting " + name + " for " + username + "..."); 1.709 + coll.delete({}); 1.710 + } 1.711 + this.users[username].collections = {}; 1.712 + return this.timestamp(); 1.713 + }, 1.714 + 1.715 + /** 1.716 + * Simple accessor to allow collective binding and abbreviation of a bunch of 1.717 + * methods. Yay! 1.718 + * Use like this: 1.719 + * 1.720 + * let u = server.user("john"); 1.721 + * u.collection("bookmarks").wbo("abcdefg").payload; // Etc. 1.722 + * 1.723 + * @return a proxy for the user data stored in this server. 1.724 + */ 1.725 + user: function user(username) { 1.726 + let collection = this.getCollection.bind(this, username); 1.727 + let createCollection = this.createCollection.bind(this, username); 1.728 + let createContents = this.createContents.bind(this, username); 1.729 + let modified = function (collectionName) { 1.730 + return collection(collectionName).timestamp; 1.731 + } 1.732 + let deleteCollections = this.deleteCollections.bind(this, username); 1.733 + return { 1.734 + collection: collection, 1.735 + createCollection: createCollection, 1.736 + createContents: createContents, 1.737 + deleteCollections: deleteCollections, 1.738 + modified: modified 1.739 + }; 1.740 + }, 1.741 + 1.742 + /* 1.743 + * Regular expressions for splitting up Sync request paths. 1.744 + * Sync URLs are of the form: 1.745 + * /$apipath/$version/$user/$further 1.746 + * where $further is usually: 1.747 + * storage/$collection/$wbo 1.748 + * or 1.749 + * storage/$collection 1.750 + * or 1.751 + * info/$op 1.752 + * We assume for the sake of simplicity that $apipath is empty. 1.753 + * 1.754 + * N.B., we don't follow any kind of username spec here, because as far as I 1.755 + * can tell there isn't one. See Bug 689671. Instead we follow the Python 1.756 + * server code. 1.757 + * 1.758 + * Path: [all, version, username, first, rest] 1.759 + * Storage: [all, collection?, id?] 1.760 + */ 1.761 + pathRE: /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/, 1.762 + storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/, 1.763 + 1.764 + defaultHeaders: {}, 1.765 + 1.766 + /** 1.767 + * HTTP response utility. 1.768 + */ 1.769 + respond: function respond(req, resp, code, status, body, headers) { 1.770 + resp.setStatusLine(req.httpVersion, code, status); 1.771 + for each (let [header, value] in Iterator(headers || this.defaultHeaders)) { 1.772 + resp.setHeader(header, value); 1.773 + } 1.774 + resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false); 1.775 + resp.bodyOutputStream.write(body, body.length); 1.776 + }, 1.777 + 1.778 + /** 1.779 + * This is invoked by the HttpServer. `this` is bound to the SyncServer; 1.780 + * `handler` is the HttpServer's handler. 1.781 + * 1.782 + * TODO: need to use the correct Sync API response codes and errors here. 1.783 + * TODO: Basic Auth. 1.784 + * TODO: check username in path against username in BasicAuth. 1.785 + */ 1.786 + handleDefault: function handleDefault(handler, req, resp) { 1.787 + try { 1.788 + this._handleDefault(handler, req, resp); 1.789 + } catch (e) { 1.790 + if (e instanceof HttpError) { 1.791 + this.respond(req, resp, e.code, e.description, "", {}); 1.792 + } else { 1.793 + throw e; 1.794 + } 1.795 + } 1.796 + }, 1.797 + 1.798 + _handleDefault: function _handleDefault(handler, req, resp) { 1.799 + this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path); 1.800 + 1.801 + if (this.callback.onRequest) { 1.802 + this.callback.onRequest(req); 1.803 + } 1.804 + 1.805 + let parts = this.pathRE.exec(req.path); 1.806 + if (!parts) { 1.807 + this._log.debug("SyncServer: Unexpected request: bad URL " + req.path); 1.808 + throw HTTP_404; 1.809 + } 1.810 + 1.811 + let [all, version, username, first, rest] = parts; 1.812 + // Doing a float compare of the version allows for us to pretend there was 1.813 + // a node-reassignment - eg, we could re-assign from "1.1/user/" to 1.814 + // "1.10/user" - this server will then still accept requests with the new 1.815 + // URL while any code in sync itself which compares URLs will see a 1.816 + // different URL. 1.817 + if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) { 1.818 + this._log.debug("SyncServer: Unknown version."); 1.819 + throw HTTP_404; 1.820 + } 1.821 + 1.822 + if (!this.userExists(username)) { 1.823 + this._log.debug("SyncServer: Unknown user."); 1.824 + throw HTTP_401; 1.825 + } 1.826 + 1.827 + // Hand off to the appropriate handler for this path component. 1.828 + if (first in this.toplevelHandlers) { 1.829 + let handler = this.toplevelHandlers[first]; 1.830 + return handler.call(this, handler, req, resp, version, username, rest); 1.831 + } 1.832 + this._log.debug("SyncServer: Unknown top-level " + first); 1.833 + throw HTTP_404; 1.834 + }, 1.835 + 1.836 + /** 1.837 + * Compute the object that is returned for an info/collections request. 1.838 + */ 1.839 + infoCollections: function infoCollections(username) { 1.840 + let responseObject = {}; 1.841 + let colls = this.users[username].collections; 1.842 + for (let coll in colls) { 1.843 + responseObject[coll] = colls[coll].timestamp; 1.844 + } 1.845 + this._log.trace("SyncServer: info/collections returning " + 1.846 + JSON.stringify(responseObject)); 1.847 + return responseObject; 1.848 + }, 1.849 + 1.850 + /** 1.851 + * Collection of the handler methods we use for top-level path components. 1.852 + */ 1.853 + toplevelHandlers: { 1.854 + "storage": function handleStorage(handler, req, resp, version, username, rest) { 1.855 + let respond = this.respond.bind(this, req, resp); 1.856 + if (!rest || !rest.length) { 1.857 + this._log.debug("SyncServer: top-level storage " + 1.858 + req.method + " request."); 1.859 + 1.860 + // TODO: verify if this is spec-compliant. 1.861 + if (req.method != "DELETE") { 1.862 + respond(405, "Method Not Allowed", "[]", {"Allow": "DELETE"}); 1.863 + return undefined; 1.864 + } 1.865 + 1.866 + // Delete all collections and track the timestamp for the response. 1.867 + let timestamp = this.user(username).deleteCollections(); 1.868 + 1.869 + // Return timestamp and OK for deletion. 1.870 + respond(200, "OK", JSON.stringify(timestamp)); 1.871 + return undefined; 1.872 + } 1.873 + 1.874 + let match = this.storageRE.exec(rest); 1.875 + if (!match) { 1.876 + this._log.warn("SyncServer: Unknown storage operation " + rest); 1.877 + throw HTTP_404; 1.878 + } 1.879 + let [all, collection, wboID] = match; 1.880 + let coll = this.getCollection(username, collection); 1.881 + switch (req.method) { 1.882 + case "GET": 1.883 + if (!coll) { 1.884 + if (wboID) { 1.885 + respond(404, "Not found", "Not found"); 1.886 + return undefined; 1.887 + } 1.888 + // *cries inside*: Bug 687299. 1.889 + respond(200, "OK", "[]"); 1.890 + return undefined; 1.891 + } 1.892 + if (!wboID) { 1.893 + return coll.collectionHandler(req, resp); 1.894 + } 1.895 + let wbo = coll.wbo(wboID); 1.896 + if (!wbo) { 1.897 + respond(404, "Not found", "Not found"); 1.898 + return undefined; 1.899 + } 1.900 + return wbo.handler()(req, resp); 1.901 + 1.902 + // TODO: implement handling of X-If-Unmodified-Since for write verbs. 1.903 + case "DELETE": 1.904 + if (!coll) { 1.905 + respond(200, "OK", "{}"); 1.906 + return undefined; 1.907 + } 1.908 + if (wboID) { 1.909 + let wbo = coll.wbo(wboID); 1.910 + if (wbo) { 1.911 + wbo.delete(); 1.912 + this.callback.onItemDeleted(username, collection, wboID); 1.913 + } 1.914 + respond(200, "OK", "{}"); 1.915 + return undefined; 1.916 + } 1.917 + coll.collectionHandler(req, resp); 1.918 + 1.919 + // Spot if this is a DELETE for some IDs, and don't blow away the 1.920 + // whole collection! 1.921 + // 1.922 + // We already handled deleting the WBOs by invoking the deleted 1.923 + // collection's handler. However, in the case of 1.924 + // 1.925 + // DELETE storage/foobar 1.926 + // 1.927 + // we also need to remove foobar from the collections map. This 1.928 + // clause tries to differentiate the above request from 1.929 + // 1.930 + // DELETE storage/foobar?ids=foo,baz 1.931 + // 1.932 + // and do the right thing. 1.933 + // TODO: less hacky method. 1.934 + if (-1 == req.queryString.indexOf("ids=")) { 1.935 + // When you delete the entire collection, we drop it. 1.936 + this._log.debug("Deleting entire collection."); 1.937 + delete this.users[username].collections[collection]; 1.938 + this.callback.onCollectionDeleted(username, collection); 1.939 + } 1.940 + 1.941 + // Notify of item deletion. 1.942 + let deleted = resp.deleted || []; 1.943 + for (let i = 0; i < deleted.length; ++i) { 1.944 + this.callback.onItemDeleted(username, collection, deleted[i]); 1.945 + } 1.946 + return undefined; 1.947 + case "POST": 1.948 + case "PUT": 1.949 + if (!coll) { 1.950 + coll = this.createCollection(username, collection); 1.951 + } 1.952 + if (wboID) { 1.953 + let wbo = coll.wbo(wboID); 1.954 + if (!wbo) { 1.955 + this._log.trace("SyncServer: creating WBO " + collection + "/" + wboID); 1.956 + wbo = coll.insert(wboID); 1.957 + } 1.958 + // Rather than instantiate each WBO's handler function, do it once 1.959 + // per request. They get hit far less often than do collections. 1.960 + wbo.handler()(req, resp); 1.961 + coll.timestamp = resp.newModified; 1.962 + return resp; 1.963 + } 1.964 + return coll.collectionHandler(req, resp); 1.965 + default: 1.966 + throw "Request method " + req.method + " not implemented."; 1.967 + } 1.968 + }, 1.969 + 1.970 + "info": function handleInfo(handler, req, resp, version, username, rest) { 1.971 + switch (rest) { 1.972 + case "collections": 1.973 + let body = JSON.stringify(this.infoCollections(username)); 1.974 + this.respond(req, resp, 200, "OK", body, { 1.975 + "Content-Type": "application/json" 1.976 + }); 1.977 + return; 1.978 + case "collection_usage": 1.979 + case "collection_counts": 1.980 + case "quota": 1.981 + // TODO: implement additional info methods. 1.982 + this.respond(req, resp, 200, "OK", "TODO"); 1.983 + return; 1.984 + default: 1.985 + // TODO 1.986 + this._log.warn("SyncServer: Unknown info operation " + rest); 1.987 + throw HTTP_404; 1.988 + } 1.989 + } 1.990 + } 1.991 +}; 1.992 + 1.993 +/** 1.994 + * Test helper. 1.995 + */ 1.996 +function serverForUsers(users, contents, callback) { 1.997 + let server = new SyncServer(callback); 1.998 + for (let [user, pass] in Iterator(users)) { 1.999 + server.registerUser(user, pass); 1.1000 + server.createContents(user, contents); 1.1001 + } 1.1002 + server.start(); 1.1003 + return server; 1.1004 +}