diff options
Diffstat (limited to 'services/sync/tests/unit/head_http_server.js')
-rw-r--r-- | services/sync/tests/unit/head_http_server.js | 1265 |
1 files changed, 1265 insertions, 0 deletions
diff --git a/services/sync/tests/unit/head_http_server.js b/services/sync/tests/unit/head_http_server.js new file mode 100644 index 0000000000..84dbb33951 --- /dev/null +++ b/services/sync/tests/unit/head_http_server.js @@ -0,0 +1,1265 @@ +/* import-globals-from head_appinfo.js */ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ +/* import-globals-from head_helpers.js */ + +var Cm = Components.manager; + +// Shared logging for all HTTP server functions. +var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs"); +var { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +var { + MockFxaStorageManager, + SyncTestingInfrastructure, + configureFxAccountIdentity, + configureIdentity, + encryptPayload, + getLoginTelemetryScalar, + makeFxAccountsInternalMock, + makeIdentityConfig, + promiseNamedTimer, + promiseZeroTimer, + sumHistogram, + syncTestLogging, + waitForZeroTimer, +} = ChromeUtils.importESModule( + "resource://testing-common/services/sync/utils.sys.mjs" +); + +const SYNC_HTTP_LOGGER = "Sync.Test.Server"; + +// While the sync code itself uses 1.5, the tests hard-code 1.1, +// so we're sticking with 1.1 here. +const SYNC_API_VERSION = "1.1"; + +// Use the same method that record.js does, which mirrors the server. +// The server returns timestamps with 1/100 sec granularity. Note that this is +// subject to change: see Bug 650435. +function new_timestamp() { + return round_timestamp(Date.now()); +} + +// Rounds a millisecond timestamp `t` to seconds, with centisecond precision. +function round_timestamp(t) { + return Math.round(t / 10) / 100; +} + +function return_timestamp(request, response, timestamp) { + if (!timestamp) { + timestamp = new_timestamp(); + } + let body = "" + timestamp; + response.setHeader("X-Weave-Timestamp", body); + response.setStatusLine(request.httpVersion, 200, "OK"); + writeBytesToOutputStream(response.bodyOutputStream, body); + return timestamp; +} + +function has_hawk_header(req) { + return ( + req.hasHeader("Authorization") && + req.getHeader("Authorization").startsWith("Hawk") + ); +} + +function basic_auth_header(user, password) { + return "Basic " + btoa(user + ":" + CommonUtils.encodeUTF8(password)); +} + +function basic_auth_matches(req, user, password) { + if (!req.hasHeader("Authorization")) { + return false; + } + + let expected = basic_auth_header(user, CommonUtils.encodeUTF8(password)); + return req.getHeader("Authorization") == expected; +} + +function httpd_basic_auth_handler(body, metadata, response) { + if (basic_auth_matches(metadata, "guest", "guest")) { + response.setStatusLine(metadata.httpVersion, 200, "OK, authorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } else { + body = "This path exists and is protected - failed"; + response.setStatusLine(metadata.httpVersion, 401, "Unauthorized"); + response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false); + } + writeBytesToOutputStream(response.bodyOutputStream, body); +} + +/* + * Represent a WBO on the server + */ +function ServerWBO(id, initialPayload, modified) { + if (!id) { + throw new Error("No ID for ServerWBO!"); + } + this.id = id; + if (!initialPayload) { + return; + } + + if (typeof initialPayload == "object") { + initialPayload = JSON.stringify(initialPayload); + } + this.payload = initialPayload; + this.modified = modified || new_timestamp(); + this.sortindex = 0; +} +ServerWBO.prototype = { + get data() { + return JSON.parse(this.payload); + }, + + get() { + return { id: this.id, modified: this.modified, payload: this.payload }; + }, + + put(input) { + input = JSON.parse(input); + this.payload = input.payload; + this.modified = new_timestamp(); + this.sortindex = input.sortindex || 0; + }, + + delete() { + delete this.payload; + delete this.modified; + delete this.sortindex; + }, + + // This handler sets `newModified` on the response body if the collection + // timestamp has changed. This allows wrapper handlers to extract information + // that otherwise would exist only in the body stream. + handler() { + let self = this; + + return function (request, response) { + var statusCode = 200; + var status = "OK"; + var body; + + switch (request.method) { + case "GET": + if (self.payload) { + body = JSON.stringify(self.get()); + } else { + statusCode = 404; + status = "Not Found"; + body = "Not Found"; + } + break; + + case "PUT": + self.put(readBytesFromInputStream(request.bodyInputStream)); + body = JSON.stringify(self.modified); + response.setHeader("Content-Type", "application/json"); + response.newModified = self.modified; + break; + + case "DELETE": + self.delete(); + let ts = new_timestamp(); + body = JSON.stringify(ts); + response.setHeader("Content-Type", "application/json"); + response.newModified = ts; + break; + } + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + response.setStatusLine(request.httpVersion, statusCode, status); + writeBytesToOutputStream(response.bodyOutputStream, body); + }; + }, + + /** + * Get the cleartext data stored in the payload. + * + * This isn't `get cleartext`, because `x.cleartext.blah = 3;` wouldn't work, + * which seems like a footgun. + */ + getCleartext() { + return JSON.parse(JSON.parse(this.payload).ciphertext); + }, + + /** + * Setter for getCleartext(), but lets you adjust the modified timestamp too. + * Returns this ServerWBO object. + */ + setCleartext(cleartext, modifiedTimestamp = this.modified) { + this.payload = JSON.stringify(encryptPayload(cleartext)); + this.modified = modifiedTimestamp; + return this; + }, +}; + +/** + * Represent a collection on the server. The '_wbos' attribute is a + * mapping of id -> ServerWBO objects. + * + * Note that if you want these records to be accessible individually, + * you need to register their handlers with the server separately, or use a + * containing HTTP server that will do so on your behalf. + * + * @param wbos + * An object mapping WBO IDs to ServerWBOs. + * @param acceptNew + * If true, POSTs to this collection URI will result in new WBOs being + * created and wired in on the fly. + * @param timestamp + * An optional timestamp value to initialize the modified time of the + * collection. This should be in the format returned by new_timestamp(). + * + * @return the new ServerCollection instance. + * + */ +function ServerCollection(wbos, acceptNew, timestamp) { + this._wbos = wbos || {}; + this.acceptNew = acceptNew || false; + + /* + * Track modified timestamp. + * We can't just use the timestamps of contained WBOs: an empty collection + * has a modified time. + */ + this.timestamp = timestamp || new_timestamp(); + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); +} +ServerCollection.prototype = { + /** + * Convenience accessor for our WBO keys. + * Excludes deleted items, of course. + * + * @param filter + * A predicate function (applied to the ID and WBO) which dictates + * whether to include the WBO's ID in the output. + * + * @return an array of IDs. + */ + keys: function keys(filter) { + let ids = []; + for (let [id, wbo] of Object.entries(this._wbos)) { + if (wbo.payload && (!filter || filter(id, wbo))) { + ids.push(id); + } + } + return ids; + }, + + /** + * Convenience method to get an array of WBOs. + * Optionally provide a filter function. + * + * @param filter + * A predicate function, applied to the WBO, which dictates whether to + * include the WBO in the output. + * + * @return an array of ServerWBOs. + */ + wbos: function wbos(filter) { + let os = []; + for (let wbo of Object.values(this._wbos)) { + if (wbo.payload) { + os.push(wbo); + } + } + + if (filter) { + return os.filter(filter); + } + return os; + }, + + /** + * Convenience method to get an array of parsed ciphertexts. + * + * @return an array of the payloads of each stored WBO. + */ + payloads() { + return this.wbos().map(wbo => wbo.getCleartext()); + }, + + // Just for syntactic elegance. + wbo: function wbo(id) { + return this._wbos[id]; + }, + + payload: function payload(id) { + return this.wbo(id).payload; + }, + + cleartext(id) { + return this.wbo(id).getCleartext(); + }, + + /** + * Insert the provided WBO under its ID. + * + * @return the provided WBO. + */ + insertWBO: function insertWBO(wbo) { + this.timestamp = Math.max(this.timestamp, wbo.modified); + return (this._wbos[wbo.id] = wbo); + }, + + /** + * Update an existing WBO's cleartext using a callback function that modifies + * the record in place, or returns a new record. + */ + updateRecord(id, updateCallback, optTimestamp) { + let wbo = this.wbo(id); + if (!wbo) { + throw new Error("No record with provided ID"); + } + let curCleartext = wbo.getCleartext(); + // Allow update callback to either return a new cleartext, or modify in place. + let newCleartext = updateCallback(curCleartext) || curCleartext; + wbo.setCleartext(newCleartext, optTimestamp); + // It is already inserted, but we might need to update our timestamp based + // on it's `modified` value, if `optTimestamp` was provided. + return this.insertWBO(wbo); + }, + + /** + * Insert a record, which may either an object with a cleartext property, or + * the cleartext property itself. + */ + insertRecord(record, timestamp = Math.round(Date.now() / 10) / 100) { + if (typeof timestamp != "number") { + throw new TypeError("insertRecord: Timestamp is not a number."); + } + if (!record.id) { + throw new Error("Attempt to insert record with no id"); + } + // Allow providing either the cleartext directly, or the CryptoWrapper-like. + let cleartext = record.cleartext || record; + return this.insert(record.id, encryptPayload(cleartext), timestamp); + }, + + /** + * Insert the provided payload as part of a new ServerWBO with the provided + * ID. + * + * @param id + * The GUID for the WBO. + * @param payload + * The payload, as provided to the ServerWBO constructor. + * @param modified + * An optional modified time for the ServerWBO. + * + * @return the inserted WBO. + */ + insert: function insert(id, payload, modified) { + return this.insertWBO(new ServerWBO(id, payload, modified)); + }, + + /** + * Removes an object entirely from the collection. + * + * @param id + * (string) ID to remove. + */ + remove: function remove(id) { + delete this._wbos[id]; + }, + + _inResultSet(wbo, options) { + return ( + wbo.payload && + (!options.ids || options.ids.includes(wbo.id)) && + (!options.newer || wbo.modified > options.newer) && + (!options.older || wbo.modified < options.older) + ); + }, + + count(options) { + options = options || {}; + let c = 0; + for (let wbo of Object.values(this._wbos)) { + if (wbo.modified && this._inResultSet(wbo, options)) { + c++; + } + } + return c; + }, + + get(options, request) { + let data = []; + for (let wbo of Object.values(this._wbos)) { + if (wbo.modified && this._inResultSet(wbo, options)) { + data.push(wbo); + } + } + switch (options.sort) { + case "newest": + data.sort((a, b) => b.modified - a.modified); + break; + + case "oldest": + data.sort((a, b) => a.modified - b.modified); + break; + + case "index": + data.sort((a, b) => b.sortindex - a.sortindex); + break; + + default: + if (options.sort) { + this._log.error( + "Error: client requesting unknown sort order", + options.sort + ); + throw new Error("Unknown sort order"); + } + // If the client didn't request a sort order, shuffle the records + // to ensure that we don't accidentally depend on the default order. + TestUtils.shuffle(data); + } + if (options.full) { + data = data.map(wbo => wbo.get()); + let start = options.offset || 0; + if (options.limit) { + let numItemsPastOffset = data.length - start; + data = data.slice(start, start + options.limit); + // use options as a backchannel to set x-weave-next-offset + if (numItemsPastOffset > options.limit) { + options.nextOffset = start + options.limit; + } + } else if (start) { + data = data.slice(start); + } + + if (request && request.getHeader("accept") == "application/newlines") { + this._log.error( + "Error: client requesting application/newlines content" + ); + throw new Error( + "This server should not serve application/newlines content" + ); + } + + // Use options as a backchannel to report count. + options.recordCount = data.length; + } else { + data = data.map(wbo => wbo.id); + let start = options.offset || 0; + if (options.limit) { + data = data.slice(start, start + options.limit); + options.nextOffset = start + options.limit; + } else if (start) { + data = data.slice(start); + } + options.recordCount = data.length; + } + return JSON.stringify(data); + }, + + post(input) { + input = JSON.parse(input); + let success = []; + let failed = {}; + + // This will count records where we have an existing ServerWBO + // registered with us as successful and all other records as failed. + for (let key in input) { + let record = input[key]; + let wbo = this.wbo(record.id); + if (!wbo && this.acceptNew) { + this._log.debug( + "Creating WBO " + JSON.stringify(record.id) + " on the fly." + ); + wbo = new ServerWBO(record.id); + this.insertWBO(wbo); + } + if (wbo) { + wbo.payload = record.payload; + wbo.modified = new_timestamp(); + wbo.sortindex = record.sortindex || 0; + success.push(record.id); + } else { + failed[record.id] = "no wbo configured"; + } + } + return { modified: new_timestamp(), success, failed }; + }, + + delete(options) { + let deleted = []; + for (let wbo of Object.values(this._wbos)) { + if (this._inResultSet(wbo, options)) { + this._log.debug("Deleting " + JSON.stringify(wbo)); + deleted.push(wbo.id); + wbo.delete(); + } + } + return deleted; + }, + + // This handler sets `newModified` on the response body if the collection + // timestamp has changed. + handler() { + let self = this; + + return function (request, response) { + var statusCode = 200; + var status = "OK"; + var body; + + // Parse queryString + let options = {}; + for (let chunk of request.queryString.split("&")) { + if (!chunk) { + continue; + } + chunk = chunk.split("="); + if (chunk.length == 1) { + options[chunk[0]] = ""; + } else { + options[chunk[0]] = chunk[1]; + } + } + // The real servers return 400 if ids= is specified without a list of IDs. + if (options.hasOwnProperty("ids")) { + if (!options.ids) { + response.setStatusLine(request.httpVersion, "400", "Bad Request"); + body = "Bad Request"; + writeBytesToOutputStream(response.bodyOutputStream, body); + return; + } + options.ids = options.ids.split(","); + } + if (options.newer) { + options.newer = parseFloat(options.newer); + } + if (options.older) { + options.older = parseFloat(options.older); + } + if (options.limit) { + options.limit = parseInt(options.limit, 10); + } + if (options.offset) { + options.offset = parseInt(options.offset, 10); + } + + switch (request.method) { + case "GET": + body = self.get(options, request); + // see http://moz-services-docs.readthedocs.io/en/latest/storage/apis-1.5.html + // for description of these headers. + let { recordCount: records, nextOffset } = options; + + self._log.info("Records: " + records + ", nextOffset: " + nextOffset); + if (records != null) { + response.setHeader("X-Weave-Records", "" + records); + } + if (nextOffset) { + response.setHeader("X-Weave-Next-Offset", "" + nextOffset); + } + response.setHeader("X-Last-Modified", "" + self.timestamp); + break; + + case "POST": + let res = self.post( + readBytesFromInputStream(request.bodyInputStream), + request + ); + body = JSON.stringify(res); + response.newModified = res.modified; + break; + + case "DELETE": + self._log.debug("Invoking ServerCollection.DELETE."); + let deleted = self.delete(options, request); + let ts = new_timestamp(); + body = JSON.stringify(ts); + response.newModified = ts; + response.deleted = deleted; + break; + } + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + + // Update the collection timestamp to the appropriate modified time. + // This is either a value set by the handler, or the current time. + if (request.method != "GET") { + self.timestamp = + response.newModified >= 0 ? response.newModified : new_timestamp(); + } + response.setHeader("X-Last-Modified", "" + self.timestamp, false); + + response.setStatusLine(request.httpVersion, statusCode, status); + writeBytesToOutputStream(response.bodyOutputStream, body); + }; + }, +}; + +/* + * Test setup helpers. + */ +function sync_httpd_setup(handlers) { + handlers["/1.1/foo/storage/meta/global"] = new ServerWBO( + "global", + {} + ).handler(); + return httpd_setup(handlers); +} + +/* + * Track collection modified times. Return closures. + * + * XXX - DO NOT USE IN NEW TESTS + * + * This code has very limited and very hacky timestamp support - the test + * server now has more complete and correct support - using this helper + * may cause strangeness wrt timestamp headers and 412 responses. + */ +function track_collections_helper() { + /* + * Our tracking object. + */ + let collections = {}; + + /* + * Update the timestamp of a collection. + */ + function update_collection(coll, ts) { + _("Updating collection " + coll + " to " + ts); + let timestamp = ts || new_timestamp(); + collections[coll] = timestamp; + } + + /* + * Invoke a handler, updating the collection's modified timestamp unless + * it's a GET request. + */ + function with_updated_collection(coll, f) { + return function (request, response) { + f.call(this, request, response); + + // Update the collection timestamp to the appropriate modified time. + // This is either a value set by the handler, or the current time. + if (request.method != "GET") { + update_collection(coll, response.newModified); + } + }; + } + + /* + * Return the info/collections object. + */ + function info_collections(request, response) { + let body = "Error."; + switch (request.method) { + case "GET": + body = JSON.stringify(collections); + break; + default: + throw new Error("Non-GET on info_collections."); + } + + response.setHeader("Content-Type", "application/json"); + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + response.setStatusLine(request.httpVersion, 200, "OK"); + writeBytesToOutputStream(response.bodyOutputStream, body); + } + + return { + collections, + handler: info_collections, + with_updated_collection, + update_collection, + }; +} + +// ===========================================================================// +// httpd.js-based Sync server. // +// ===========================================================================// + +/** + * In general, the preferred way of using SyncServer is to directly introspect + * it. Callbacks are available for operations which are hard to verify through + * introspection, such as deletions. + * + * One of the goals of this server is to provide enough hooks for test code to + * find out what it needs without monkeypatching. Use this object as your + * prototype, and override as appropriate. + */ +var SyncServerCallback = { + onCollectionDeleted: function onCollectionDeleted(user, collection) {}, + onItemDeleted: function onItemDeleted(user, collection, wboID) {}, + + /** + * Called at the top of every request. + * + * Allows the test to inspect the request. Hooks should be careful not to + * modify or change state of the request or they may impact future processing. + * The response is also passed so the callback can set headers etc - but care + * must be taken to not screw with the response body or headers that may + * conflict with normal operation of this server. + */ + onRequest: function onRequest(request, response) {}, +}; + +/** + * Construct a new test Sync server. Takes a callback object (e.g., + * SyncServerCallback) as input. + */ +function SyncServer(callback) { + this.callback = callback || Object.create(SyncServerCallback); + this.server = new HttpServer(); + this.started = false; + this.users = {}; + this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER); + + // Install our own default handler. This allows us to mess around with the + // whole URL space. + let handler = this.server._handler; + handler._handleDefault = this.handleDefault.bind(this, handler); +} +SyncServer.prototype = { + server: null, // HttpServer. + users: null, // Map of username => {collections, password}. + + /** + * Start the SyncServer's underlying HTTP server. + * + * @param port + * The numeric port on which to start. -1 implies the default, a + * randomly chosen port. + * @param cb + * A callback function (of no arguments) which is invoked after + * startup. + */ + start: function start(port = -1, cb) { + if (this.started) { + this._log.warn("Warning: server already started on " + this.port); + return; + } + try { + this.server.start(port); + let i = this.server.identity; + this.port = i.primaryPort; + this.baseURI = + i.primaryScheme + "://" + i.primaryHost + ":" + i.primaryPort + "/"; + this.started = true; + if (cb) { + cb(); + } + } catch (ex) { + _("=========================================="); + _("Got exception starting Sync HTTP server."); + _("Error: " + Log.exceptionStr(ex)); + _("Is there a process already listening on port " + port + "?"); + _("=========================================="); + do_throw(ex); + } + }, + + /** + * Stop the SyncServer's HTTP server. + * + * @param cb + * A callback function. Invoked after the server has been stopped. + * + */ + stop: function stop(cb) { + if (!this.started) { + this._log.warn( + "SyncServer: Warning: server not running. Can't stop me now!" + ); + return; + } + + this.server.stop(cb); + this.started = false; + }, + + /** + * Return a server timestamp for a record. + * The server returns timestamps with 1/100 sec granularity. Note that this is + * subject to change: see Bug 650435. + */ + timestamp: function timestamp() { + return new_timestamp(); + }, + + /** + * Create a new user, complete with an empty set of collections. + * + * @param username + * The username to use. An Error will be thrown if a user by that name + * already exists. + * @param password + * A password string. + * + * @return a user object, as would be returned by server.user(username). + */ + registerUser: function registerUser(username, password) { + if (username in this.users) { + throw new Error("User already exists."); + } + this.users[username] = { + password, + collections: {}, + }; + return this.user(username); + }, + + userExists: function userExists(username) { + return username in this.users; + }, + + getCollection: function getCollection(username, collection) { + return this.users[username].collections[collection]; + }, + + _insertCollection: function _insertCollection(collections, collection, wbos) { + let coll = new ServerCollection(wbos, true); + coll.collectionHandler = coll.handler(); + collections[collection] = coll; + return coll; + }, + + createCollection: function createCollection(username, collection, wbos) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let collections = this.users[username].collections; + if (collection in collections) { + throw new Error("Collection already exists."); + } + return this._insertCollection(collections, collection, wbos); + }, + + /** + * Accept a map like the following: + * { + * meta: {global: {version: 1, ...}}, + * crypto: {"keys": {}, foo: {bar: 2}}, + * bookmarks: {} + * } + * to cause collections and WBOs to be created. + * If a collection already exists, no error is raised. + * If a WBO already exists, it will be updated to the new contents. + */ + createContents: function createContents(username, collections) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + for (let [id, contents] of Object.entries(collections)) { + let coll = + userCollections[id] || this._insertCollection(userCollections, id); + for (let [wboID, payload] of Object.entries(contents)) { + coll.insert(wboID, payload); + } + } + }, + + /** + * Insert a WBO in an existing collection. + */ + insertWBO: function insertWBO(username, collection, wbo) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + if (!(collection in userCollections)) { + throw new Error("Unknown collection."); + } + userCollections[collection].insertWBO(wbo); + return wbo; + }, + + /** + * Delete all of the collections for the named user. + * + * @param username + * The name of the affected user. + * + * @return a timestamp. + */ + deleteCollections: function deleteCollections(username) { + if (!(username in this.users)) { + throw new Error("Unknown user."); + } + let userCollections = this.users[username].collections; + for (let name in userCollections) { + let coll = userCollections[name]; + this._log.trace("Bulk deleting " + name + " for " + username + "..."); + coll.delete({}); + } + this.users[username].collections = {}; + return this.timestamp(); + }, + + /** + * Simple accessor to allow collective binding and abbreviation of a bunch of + * methods. Yay! + * Use like this: + * + * let u = server.user("john"); + * u.collection("bookmarks").wbo("abcdefg").payload; // Etc. + * + * @return a proxy for the user data stored in this server. + */ + user: function user(username) { + let collection = this.getCollection.bind(this, username); + let createCollection = this.createCollection.bind(this, username); + let createContents = this.createContents.bind(this, username); + let modified = function (collectionName) { + return collection(collectionName).timestamp; + }; + let deleteCollections = this.deleteCollections.bind(this, username); + return { + collection, + createCollection, + createContents, + deleteCollections, + modified, + }; + }, + + /* + * Regular expressions for splitting up Sync request paths. + * Sync URLs are of the form: + * /$apipath/$version/$user/$further + * where $further is usually: + * storage/$collection/$wbo + * or + * storage/$collection + * or + * info/$op + * We assume for the sake of simplicity that $apipath is empty. + * + * N.B., we don't follow any kind of username spec here, because as far as I + * can tell there isn't one. See Bug 689671. Instead we follow the Python + * server code. + * + * Path: [all, version, username, first, rest] + * Storage: [all, collection?, id?] + */ + pathRE: + /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/, + storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/, + + defaultHeaders: {}, + + /** + * HTTP response utility. + */ + respond: function respond(req, resp, code, status, body, headers) { + resp.setStatusLine(req.httpVersion, code, status); + if (!headers) { + headers = this.defaultHeaders; + } + for (let header in headers) { + let value = headers[header]; + resp.setHeader(header, value); + } + resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false); + writeBytesToOutputStream(resp.bodyOutputStream, body); + }, + + /** + * This is invoked by the HttpServer. `this` is bound to the SyncServer; + * `handler` is the HttpServer's handler. + * + * TODO: need to use the correct Sync API response codes and errors here. + * TODO: Basic Auth. + * TODO: check username in path against username in BasicAuth. + */ + handleDefault: function handleDefault(handler, req, resp) { + try { + this._handleDefault(handler, req, resp); + } catch (e) { + if (e instanceof HttpError) { + this.respond(req, resp, e.code, e.description, "", {}); + } else { + throw e; + } + } + }, + + _handleDefault: function _handleDefault(handler, req, resp) { + this._log.debug( + "SyncServer: Handling request: " + req.method + " " + req.path + ); + + if (this.callback.onRequest) { + this.callback.onRequest(req, resp); + } + + let parts = this.pathRE.exec(req.path); + if (!parts) { + this._log.debug("SyncServer: Unexpected request: bad URL " + req.path); + throw HTTP_404; + } + + let [, version, username, first, rest] = parts; + // Doing a float compare of the version allows for us to pretend there was + // a node-reassignment - eg, we could re-assign from "1.1/user/" to + // "1.10/user" - this server will then still accept requests with the new + // URL while any code in sync itself which compares URLs will see a + // different URL. + if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) { + this._log.debug("SyncServer: Unknown version."); + throw HTTP_404; + } + + if (!this.userExists(username)) { + this._log.debug("SyncServer: Unknown user."); + throw HTTP_401; + } + + // Hand off to the appropriate handler for this path component. + if (first in this.toplevelHandlers) { + let newHandler = this.toplevelHandlers[first]; + return newHandler.call( + this, + newHandler, + req, + resp, + version, + username, + rest + ); + } + this._log.debug("SyncServer: Unknown top-level " + first); + throw HTTP_404; + }, + + /** + * Compute the object that is returned for an info/collections request. + */ + infoCollections: function infoCollections(username) { + let responseObject = {}; + let colls = this.users[username].collections; + for (let coll in colls) { + responseObject[coll] = colls[coll].timestamp; + } + this._log.trace( + "SyncServer: info/collections returning " + JSON.stringify(responseObject) + ); + return responseObject; + }, + + /** + * Collection of the handler methods we use for top-level path components. + */ + toplevelHandlers: { + storage: function handleStorage( + handler, + req, + resp, + version, + username, + rest + ) { + let respond = this.respond.bind(this, req, resp); + if (!rest || !rest.length) { + this._log.debug( + "SyncServer: top-level storage " + req.method + " request." + ); + + // TODO: verify if this is spec-compliant. + if (req.method != "DELETE") { + respond(405, "Method Not Allowed", "[]", { Allow: "DELETE" }); + return undefined; + } + + // Delete all collections and track the timestamp for the response. + let timestamp = this.user(username).deleteCollections(); + + // Return timestamp and OK for deletion. + respond(200, "OK", JSON.stringify(timestamp)); + return undefined; + } + + let match = this.storageRE.exec(rest); + if (!match) { + this._log.warn("SyncServer: Unknown storage operation " + rest); + throw HTTP_404; + } + let [, collection, wboID] = match; + let coll = this.getCollection(username, collection); + + let checkXIUSFailure = () => { + if (req.hasHeader("x-if-unmodified-since")) { + let xius = parseFloat(req.getHeader("x-if-unmodified-since")); + // Sadly the way our tests are setup, we often end up with xius of + // zero (typically when syncing just one engine, so the date from + // info/collections isn't used) - so we allow that to work. + // Further, the Python server treats non-existing collections as + // having a timestamp of 0. + let collTimestamp = coll ? coll.timestamp : 0; + if (xius && xius < collTimestamp) { + this._log.info( + `x-if-unmodified-since mismatch - request wants ${xius} but our collection has ${collTimestamp}` + ); + respond(412, "precondition failed", "precondition failed"); + return true; + } + } + return false; + }; + + switch (req.method) { + case "GET": { + if (!coll) { + if (wboID) { + respond(404, "Not found", "Not found"); + return undefined; + } + // *cries inside*: - apparently the real sync server returned 200 + // here for some time, then returned 404 for some time (bug 687299), + // and now is back to 200 (bug 963332). + respond(200, "OK", "[]"); + return undefined; + } + if (!wboID) { + return coll.collectionHandler(req, resp); + } + let wbo = coll.wbo(wboID); + if (!wbo) { + respond(404, "Not found", "Not found"); + return undefined; + } + return wbo.handler()(req, resp); + } + case "DELETE": { + if (!coll) { + respond(200, "OK", "{}"); + return undefined; + } + if (checkXIUSFailure()) { + return undefined; + } + if (wboID) { + let wbo = coll.wbo(wboID); + if (wbo) { + wbo.delete(); + this.callback.onItemDeleted(username, collection, wboID); + } + respond(200, "OK", "{}"); + return undefined; + } + coll.collectionHandler(req, resp); + + // Spot if this is a DELETE for some IDs, and don't blow away the + // whole collection! + // + // We already handled deleting the WBOs by invoking the deleted + // collection's handler. However, in the case of + // + // DELETE storage/foobar + // + // we also need to remove foobar from the collections map. This + // clause tries to differentiate the above request from + // + // DELETE storage/foobar?ids=foo,baz + // + // and do the right thing. + // TODO: less hacky method. + if (-1 == req.queryString.indexOf("ids=")) { + // When you delete the entire collection, we drop it. + this._log.debug("Deleting entire collection."); + delete this.users[username].collections[collection]; + this.callback.onCollectionDeleted(username, collection); + } + + // Notify of item deletion. + let deleted = resp.deleted || []; + for (let i = 0; i < deleted.length; ++i) { + this.callback.onItemDeleted(username, collection, deleted[i]); + } + return undefined; + } + case "PUT": + // PUT and POST have slightly different XIUS semantics - for PUT, + // the check is against the item, whereas for POST it is against + // the collection. So first, a special-case for PUT. + if (req.hasHeader("x-if-unmodified-since")) { + let xius = parseFloat(req.getHeader("x-if-unmodified-since")); + // treat and xius of zero as if it wasn't specified - this happens + // in some of our tests for a new collection. + if (xius > 0) { + let wbo = coll.wbo(wboID); + if (xius < wbo.modified) { + this._log.info( + `x-if-unmodified-since mismatch - request wants ${xius} but wbo has ${wbo.modified}` + ); + respond(412, "precondition failed", "precondition failed"); + return undefined; + } + wbo.handler()(req, resp); + coll.timestamp = resp.newModified; + return resp; + } + } + // fall through to post. + case "POST": + if (checkXIUSFailure()) { + return undefined; + } + if (!coll) { + coll = this.createCollection(username, collection); + } + + if (wboID) { + let wbo = coll.wbo(wboID); + if (!wbo) { + this._log.trace( + "SyncServer: creating WBO " + collection + "/" + wboID + ); + wbo = coll.insert(wboID); + } + // Rather than instantiate each WBO's handler function, do it once + // per request. They get hit far less often than do collections. + wbo.handler()(req, resp); + coll.timestamp = resp.newModified; + return resp; + } + return coll.collectionHandler(req, resp); + default: + throw new Error("Request method " + req.method + " not implemented."); + } + }, + + info: function handleInfo(handler, req, resp, version, username, rest) { + switch (rest) { + case "collections": + let body = JSON.stringify(this.infoCollections(username)); + this.respond(req, resp, 200, "OK", body, { + "Content-Type": "application/json", + }); + return; + case "collection_usage": + case "collection_counts": + case "quota": + // TODO: implement additional info methods. + this.respond(req, resp, 200, "OK", "TODO"); + return; + default: + // TODO + this._log.warn("SyncServer: Unknown info operation " + rest); + throw HTTP_404; + } + }, + }, +}; + +/** + * Test helper. + */ +function serverForUsers(users, contents, callback) { + let server = new SyncServer(callback); + for (let [user, pass] of Object.entries(users)) { + server.registerUser(user, pass); + server.createContents(user, contents); + } + server.start(); + return server; +} |