services/sync/tests/unit/head_http_server.js

changeset 0
6474c204b198
     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 +}

mercurial