diff options
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js')
-rw-r--r-- | toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js | 2318 |
1 files changed, 2318 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js new file mode 100644 index 0000000000..7e92eb862c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js @@ -0,0 +1,2318 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a kinto-specific test... +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +do_get_profile(); // so we can use FxAccounts + +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +const { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +const { + ExtensionStorageSyncKinto: ExtensionStorageSync, + KintoStorageTestUtils: { + cleanUpForContext, + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + idToKey, + keyToId, + KeyRingEncryptionRemoteTransformer, + }, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" +); +const { BulkKeyBundle } = ChromeUtils.importESModule( + "resource://services-sync/keys.sys.mjs" +); +const { FxAccountsKeys } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsKeys.sys.mjs" +); +const { Utils } = ChromeUtils.importESModule( + "resource://services-sync/util.sys.mjs" +); + +const { createAppInfo, promiseStartupManager } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "69"); + +function handleCannedResponse(cannedResponse, request, response) { + response.setStatusLine( + null, + cannedResponse.status.status, + cannedResponse.status.statusText + ); + // send the headers + for (let headerLine of cannedResponse.sampleHeaders) { + let headerElements = headerLine.split(":"); + response.setHeader(headerElements[0], headerElements[1].trimLeft()); + } + response.setHeader("Date", new Date().toUTCString()); + + response.write(cannedResponse.responseBody); +} + +function collectionPath(collectionId) { + return `/buckets/default/collections/${collectionId}`; +} + +function collectionRecordsPath(collectionId) { + return `/buckets/default/collections/${collectionId}/records`; +} + +class KintoServer { + constructor() { + // Set up an HTTP Server + this.httpServer = new HttpServer(); + this.httpServer.start(-1); + + // Set<Object> corresponding to records that might be served. + // The format of these objects is defined in the documentation for #addRecord. + this.records = []; + + // Collections that we have set up access to (see `installCollection`). + this.collections = new Set(); + + // ETag to serve with responses + this.etag = 1; + + this.port = this.httpServer.identity.primaryPort; + + // POST requests we receive from the client go here + this.posts = []; + // DELETEd buckets will go here. + this.deletedBuckets = []; + // Anything in here will force the next POST to generate a conflict + this.conflicts = []; + // If this is true, reject the next request with a 401 + this.rejectNextAuthResponse = false; + this.failedAuths = []; + + this.installConfigPath(); + this.installBatchPath(); + this.installCatchAll(); + } + + clearPosts() { + this.posts = []; + } + + getPosts() { + return this.posts; + } + + getDeletedBuckets() { + return this.deletedBuckets; + } + + rejectNextAuthWith(response) { + this.rejectNextAuthResponse = response; + } + + checkAuth(request, response) { + equal(request.getHeader("Authorization"), "Bearer some-access-token"); + + if (this.rejectNextAuthResponse) { + response.setStatusLine(null, 401, "Unauthorized"); + response.write(this.rejectNextAuthResponse); + this.rejectNextAuthResponse = false; + this.failedAuths.push(request); + return true; + } + return false; + } + + installConfigPath() { + const configPath = "/v1/"; + const responseBody = JSON.stringify({ + settings: { batch_max_requests: 25 }, + url: `http://localhost:${this.port}/v1/`, + documentation: "https://kinto.readthedocs.org/", + version: "1.5.1", + commit: "cbc6f58", + hello: "kinto", + }); + const configResponse = { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 200, statusText: "OK" }, + responseBody: responseBody, + }; + + function handleGetConfig(request, response) { + if (request.method != "GET") { + dump(`ARGH, got ${request.method}\n`); + } + return handleCannedResponse(configResponse, request, response); + } + + this.httpServer.registerPathHandler(configPath, handleGetConfig); + } + + installBatchPath() { + const batchPath = "/v1/batch"; + + function handlePost(request, response) { + if (this.checkAuth(request, response)) { + return; + } + + let bodyStr = CommonUtils.readBytesFromInputStream( + request.bodyInputStream + ); + let body = JSON.parse(bodyStr); + let defaults = body.defaults; + for (let req of body.requests) { + let headers = Object.assign( + {}, + (defaults && defaults.headers) || {}, + req.headers + ); + this.posts.push(Object.assign({}, req, { headers })); + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + + let postResponse = { + responses: body.requests.map(req => { + let oneBody; + if (req.method == "DELETE") { + let id = req.path.match( + /^\/buckets\/default\/collections\/.+\/records\/(.+)$/ + )[1]; + oneBody = { + data: { + deleted: true, + id: id, + last_modified: this.etag, + }, + }; + } else { + oneBody = { + data: Object.assign({}, req.body.data, { + last_modified: this.etag, + }), + permissions: [], + }; + } + + return { + path: req.path, + status: 201, // FIXME -- only for new posts?? + headers: { ETag: 3000 }, // FIXME??? + body: oneBody, + }; + }), + }; + + if (this.conflicts.length) { + const nextConflict = this.conflicts.shift(); + if (!nextConflict.transient) { + this.records.push(nextConflict); + } + const { data } = nextConflict; + postResponse = { + responses: body.requests.map(req => { + return { + path: req.path, + status: 412, + headers: { ETag: this.etag }, // is this correct?? + body: { + details: { + existing: data, + }, + }, + }; + }), + }; + } + + response.write(JSON.stringify(postResponse)); + + // "sampleHeaders": [ + // "Access-Control-Allow-Origin: *", + // "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + // "Server: waitress", + // "Etag: \"4000\"" + // ], + } + + this.httpServer.registerPathHandler(batchPath, handlePost.bind(this)); + } + + installCatchAll() { + this.httpServer.registerPathHandler("/", (request, response) => { + dump( + `got request: ${request.method}:${request.path}?${request.queryString}\n` + ); + dump( + `${CommonUtils.readBytesFromInputStream(request.bodyInputStream)}\n` + ); + }); + } + + /** + * Add a record to those that can be served by this server. + * + * @param {object} properties An object describing the record that + * should be served. The properties of this object are: + * - collectionId {string} This record should only be served if a + * request is for this collection. + * - predicate {Function} If present, this record should only be served if the + * predicate returns true. The predicate will be called with + * {request: Request, response: Response, since: number, server: KintoServer}. + * - data {string} The record to serve. + * - conflict {boolean} If present and true, this record is added to + * "conflicts" and won't be served, but will cause a conflict on + * the next push. + */ + addRecord(properties) { + if (!properties.conflict) { + this.records.push(properties); + } else { + this.conflicts.push(properties); + } + + this.installCollection(properties.collectionId); + } + + /** + * Tell the server to set up a route for this collection. + * + * This will automatically be called for any collection to which you `addRecord`. + * + * @param {string} collectionId the collection whose route we + * should set up. + */ + installCollection(collectionId) { + if (this.collections.has(collectionId)) { + return; + } + this.collections.add(collectionId); + const remoteCollectionPath = + "/v1" + collectionPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteCollectionPath, + this.handleGetCollection.bind(this, collectionId) + ); + const remoteRecordsPath = + "/v1" + collectionRecordsPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteRecordsPath, + this.handleGetRecords.bind(this, collectionId) + ); + } + + handleGetCollection(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.write( + JSON.stringify({ + data: { + id: collectionId, + }, + }) + ); + } + + handleGetRecords(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + if (request.method != "GET") { + do_throw(`only GET is supported on ${request.path}`); + } + + let sinceMatch = request.queryString.match(/(^|&)_since=(\d+)/); + let since = sinceMatch && parseInt(sinceMatch[2], 10); + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.setHeader("ETag", this.etag.toString()); + + const records = this.records + .filter(properties => { + if (properties.collectionId != collectionId) { + return false; + } + + if (properties.predicate) { + const predAllowed = properties.predicate({ + request: request, + response: response, + since: since, + server: this, + }); + if (!predAllowed) { + return false; + } + } + + return true; + }) + .map(properties => properties.data); + + const body = JSON.stringify({ + data: records, + }); + response.write(body); + } + + installDeleteBucket() { + this.httpServer.registerPrefixHandler( + "/v1/buckets/", + (request, response) => { + if (request.method != "DELETE") { + dump( + `got a non-delete action on bucket: ${request.method} ${request.path}\n` + ); + return; + } + + const noPrefix = request.path.slice("/v1/buckets/".length); + const [bucket, afterBucket] = noPrefix.split("/", 1); + if (afterBucket && afterBucket != "") { + dump( + `got a delete for a non-bucket: ${request.method} ${request.path}\n` + ); + } + + this.deletedBuckets.push(bucket); + // Fake like this actually deletes the records. + this.records = []; + + response.write( + JSON.stringify({ + data: { + deleted: true, + last_modified: 1475161309026, + id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME + }, + }) + ); + } + ); + } + + // Utility function to install a keyring at the start of a test. + async installKeyRing(fxaService, keysData, salts, etag, properties) { + const keysRecord = { + id: "keys", + keys: keysData, + salts: salts, + last_modified: etag, + }; + this.etag = etag; + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + return this.encryptAndAddRecord( + transformer, + Object.assign({}, properties, { + collectionId: "storage-sync-crypto", + data: keysRecord, + }) + ); + } + + encryptAndAddRecord(transformer, properties) { + return transformer.encode(properties.data).then(encrypted => { + this.addRecord(Object.assign({}, properties, { data: encrypted })); + }); + } + + stop() { + this.httpServer.stop(() => {}); + } +} + +/** + * Predicate that represents a record appearing at some time. + * Requests with "_since" before this time should see this record, + * unless the server itself isn't at this time yet (etag is before + * this time). + * + * Requests with _since after this time shouldn't see this record any + * more, since it hasn't changed after this time. + * + * @param {int} startTime the etag at which time this record should + * start being available (and thus, the predicate should start + * returning true) + * @returns {Function} + */ +function appearsAt(startTime) { + return function ({ since, server }) { + return since < startTime && startTime < server.etag; + }; +} + +// Run a block of code with access to a KintoServer. +async function withServer(f) { + let server = new KintoServer(); + // Point the sync.storage client to use the test server we've just started. + Services.prefs.setCharPref( + "webextensions.storage.sync.serverURL", + `http://localhost:${server.port}/v1` + ); + try { + await f(server); + } finally { + server.stop(); + } +} + +// Run a block of code with access to both a sync context and a +// KintoServer. This is meant as a workaround for eslint's refusal to +// let me have 5 nested callbacks. +async function withContextAndServer(f) { + await withSyncContext(async function (context) { + await withServer(async function (server) { + await f(context, server); + }); + }); +} + +// Run a block of code with fxa mocked out to return a specific user. +// Calls the given function with an ExtensionStorageSync instance that +// was constructed using a mocked FxAccounts instance. +async function withSignedInUser(user, f) { + let fxaServiceMock = { + getSignedInUser() { + return Promise.resolve({ uid: user.uid }); + }, + getOAuthToken() { + return Promise.resolve("some-access-token"); + }, + checkAccountStatus() { + return Promise.resolve(true); + }, + removeCachedOAuthToken() { + return Promise.resolve(); + }, + keys: { + getKeyForScope(scope) { + return Promise.resolve({ ...user.scopedKeys[scope] }); + }, + kidAsHex(jwk) { + return new FxAccountsKeys({}).kidAsHex(jwk); + }, + }, + }; + + let telemetryMock = { + _calls: [], + _histograms: {}, + scalarSet(name, value) { + this._calls.push({ method: "scalarSet", name, value }); + }, + keyedScalarSet(name, key, value) { + this._calls.push({ method: "keyedScalarSet", name, key, value }); + }, + getKeyedHistogramById(name) { + let self = this; + return { + add(key, value) { + if (!self._histograms[name]) { + self._histograms[name] = []; + } + self._histograms[name].push(value); + }, + }; + }, + }; + let extensionStorageSync = new ExtensionStorageSync( + fxaServiceMock, + telemetryMock + ); + await f(extensionStorageSync, fxaServiceMock); +} + +// Some assertions that make it easier to write tests about what was +// posted and when. + +// Assert that a post in a batch was made with the correct access token. +// This should be true of all requests, so this is usually called from +// another assertion. +function assertAuthenticatedPost(post) { + equal(post.headers.Authorization, "Bearer some-access-token"); +} + +// Assert that this post was made with the correct request headers to +// create a new resource while protecting against someone else +// creating it at the same time (in other words, "If-None-Match: *"). +// Also calls assertAuthenticatedPost(post). +function assertPostedNewRecord(post) { + assertAuthenticatedPost(post); + equal(post.headers["If-None-Match"], "*"); +} + +// Assert that this post was made with the correct request headers to +// update an existing resource while protecting against concurrent +// modification (in other words, `If-Match: "${etag}"`). +// Also calls assertAuthenticatedPost(post). +function assertPostedUpdatedRecord(post, since) { + assertAuthenticatedPost(post); + equal(post.headers["If-Match"], `"${since}"`); +} + +// Assert that this post was an encrypted keyring, and produce the +// decrypted body. Sanity check the body while we're here. +const assertPostedEncryptedKeys = async function (fxaService, post) { + equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys"); + + let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + post.body.data + ); + ok(body.keys, `keys object should be present in decoded body`); + ok(body.keys.default, `keys object should have a default key`); + ok(body.salts, `salts object should be present in decoded body`); + return body; +}; + +// assertEqual, but for keyring[extensionId] == key. +function assertKeyRingKey(keyRing, extensionId, expectedKey, message) { + if (!message) { + message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`; + } + ok( + keyRing.hasKeysFor([extensionId]), + `expected keyring to have a key for ${extensionId}\n` + ); + deepEqual( + keyRing.keyForCollection(extensionId).keyPairB64, + expectedKey.keyPairB64, + message + ); +} + +// Assert that this post was posted for a given extension. +const assertExtensionRecord = async function ( + fxaService, + post, + extension, + key +) { + const extensionId = extension.id; + const cryptoCollection = new CryptoCollection(fxaService); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId)); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + const transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "decrypted data should be posted to path corresponding to its key" + ); + let decoded = await transformer.decode(post.body.data); + equal( + decoded.key, + key, + "decrypted data should have a key attribute corresponding to the extension data key" + ); + return decoded; +}; + +// Tests using this ID will share keys in local storage, so be careful. +const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}"; +const defaultExtension = { id: defaultExtensionId }; + +const loggedInUser = { + uid: "0123456789abcdef0123456789abcdef", + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAA", + k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMQ", + kty: "oct", + }, + }, + oauthTokens: { + "sync:addon_storage": { + token: "some-access-token", + }, + }, +}; + +function uuid() { + const uuidgen = Services.uuid; + return uuidgen.generateUUID().toString(); +} + +add_task(async function test_setup() { + await promiseStartupManager(); +}); + +add_task(async function test_single_initialization() { + // Check if we're calling openConnection too often. + const { FirefoxAdapter } = ChromeUtils.importESModule( + "resource://services-common/kinto-storage-adapter.sys.mjs" + ); + const origOpenConnection = FirefoxAdapter.openConnection; + let callCount = 0; + FirefoxAdapter.openConnection = function (...args) { + ++callCount; + return origOpenConnection.apply(this, args); + }; + function background() { + let promises = ["foo", "bar", "baz", "quux"].map(key => + browser.storage.sync.get(key) + ); + Promise.all(promises).then(() => + browser.test.notifyPass("initialize once") + ); + } + try { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})()`, + }); + + await extension.startup(); + await extension.awaitFinish("initialize once"); + await extension.unload(); + equal( + callCount, + 1, + "Initialized FirefoxAdapter connection and Kinto exactly once" + ); + } finally { + FirefoxAdapter.openConnection = origOpenConnection; + } +}); + +add_task(async function test_key_to_id() { + equal(keyToId("foo"), "key-foo"); + equal(keyToId("my-new-key"), "key-my_2D_new_2D_key"); + equal(keyToId(""), "key-"); + equal(keyToId("™"), "key-_2122_"); + equal(keyToId("\b"), "key-_8_"); + equal(keyToId("abc\ndef"), "key-abc_A_def"); + equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string"); + + const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"]; + for (let key of KEYS) { + equal(idToKey(keyToId(key)), key); + } + + equal(idToKey("hi"), null); + equal(idToKey("-key-hi"), null); + equal(idToKey("key--abcd"), null); + equal(idToKey("key-%"), null); + equal(idToKey("key-_HI"), null); + equal(idToKey("key-_HI_"), null); + equal(idToKey("key-"), ""); + equal(idToKey("key-1"), "1"); + equal(idToKey("key-_2D_"), "-"); +}); + +add_task(async function test_extension_id_to_collection_id() { + const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}"; + // FIXME: this doesn't actually require the signed in user, but the + // extensionIdToCollectionId method exists on CryptoCollection, + // which needs an fxaService to be instantiated. + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + // Fake a static keyring since the server doesn't exist. + const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo="; + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._setSalt(extensionId, salt); + + equal( + await cryptoCollection.extensionIdToCollectionId(extensionId), + "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo" + ); + } + ); +}); + +add_task(async function ensureCanSync_clearAll() { + // A test extension that will not have any active context around + // but it is returned from a call to AddonManager.getExtensionsByType. + const extensionId = "test-wipe-on-enabled-and-synced@mochi.test"; + const testExtension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: extensionId } }, + }, + }); + + await testExtension.startup(); + + // Retrieve the Extension class instance from the test extension. + const { extension } = testExtension; + + // Another test extension that will have an active extension context. + const extensionId2 = "test-wipe-on-active-context@mochi.test"; + const extension2 = { id: extensionId2 }; + + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + async function assertSetAndGetData(extension, data) { + await extensionStorageSync.set(extension, data, context); + let storedData = await extensionStorageSync.get( + extension, + Object.keys(data), + context + ); + const extId = extensionId; + deepEqual( + storedData, + data, + `${extId} should get back the data we set` + ); + } + + async function assertDataCleared(extension, keys) { + const storedData = await extensionStorageSync.get( + extension, + keys, + context + ); + deepEqual( + storedData, + {}, + `${extension.id} should have lost the data` + ); + } + + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `key isn't present for ${extensionId2}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + assertPostedNewRecord(posts[0]); + + await assertSetAndGetData(extension, { "my-key": 1 }); + await assertSetAndGetData(extension2, { "my-key": 2 }); + + // Call cleanup for the first extension, to double check it has + // been wiped out even without an active extension context. + cleanUpForContext(extension, context); + + // clear everything. + await extensionStorageSync.clearAll(); + + // Assert that the data is gone for both the extensions. + await assertDataCleared(extension, ["my-key"]); + await assertDataCleared(extension2, ["my-key"]); + + // should have been no posts caused by the clear. + posts = server.getPosts(); + equal(posts.length, 1); + } + ); + }); + + await testExtension.unload(); +}); + +add_task(async function ensureCanSync_posts_new_keys() { + const extensionId = uuid(); + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([extensionId]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + const post = posts[0]; + assertPostedNewRecord(post); + const body = await assertPostedEncryptedKeys(fxaService, post); + const oldSalt = body.salts[extensionId]; + ok( + body.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok(oldSalt, `salts object should have a salt for ${extensionId}`); + + // Try adding another key to make sure that the first post was + // OK, even on a new profile. + await extensionStorageSync.cryptoCollection._clear(); + server.clearPosts(); + // Restore the first posted keyring, but add a last_modified date + const firstPostedKeyring = Object.assign({}, post.body.data, { + last_modified: server.etag, + }); + server.addRecord({ + data: firstPostedKeyring, + collectionId: "storage-sync-crypto", + predicate: appearsAt(250), + }); + const extensionId2 = uuid(); + newKeys = await extensionStorageSync.ensureCanSync([extensionId2]); + ok( + newKeys.hasKeysFor([extensionId]), + `didn't forget key for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `new key generated for ${extensionId2}` + ); + + posts = server.getPosts(); + equal(posts.length, 1); + const newPost = posts[posts.length - 1]; + const newBody = await assertPostedEncryptedKeys(fxaService, newPost); + ok( + newBody.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok( + newBody.keys.collections[extensionId2], + `keys object should have a key for ${extensionId2}` + ); + ok( + newBody.salts[extensionId], + `salts object should have a key for ${extensionId}` + ); + ok( + newBody.salts[extensionId2], + `salts object should have a key for ${extensionId2}` + ); + equal( + oldSalt, + newBody.salts[extensionId], + `old salt should be preserved in post` + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_pulls_key() { + // ensureCanSync is implemented by adding a key to our local record + // and doing a sync. This means that if the same key exists + // remotely, we get a "conflict". Ensure that we handle this + // correctly -- we keep the server key (since presumably it's + // already been used to encrypt records) and we don't wipe out other + // collections' keys. + const extensionId = uuid(); + const extensionId2 = uuid(); + const extensionOnlyKey = uuid(); + const extensionOnlySalt = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + // FIXME: generating a random salt probably shouldn't require a CryptoCollection? + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + await extensionStorageSync.cryptoCollection._clear(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 950, { + predicate: appearsAt(900), + }); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY); + + let posts = server.getPosts(); + equal( + posts.length, + 0, + "ensureCanSync shouldn't push when the server keyring has the right key" + ); + + // Another client generates a key for extensionId2 + const newKey = new BulkKeyBundle(extensionId2); + await newKey.generateRandom(); + keysData.collections[extensionId2] = newKey.keyPairB64; + saltData[extensionId2] = cryptoCollection.getNewSalt(); + await server.installKeyRing(fxaService, keysData, saltData, 1050, { + predicate: appearsAt(1000), + }); + + let newCollectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + assertKeyRingKey(newCollectionKeys, extensionId2, newKey); + assertKeyRingKey( + newCollectionKeys, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal( + posts.length, + 0, + "ensureCanSync shouldn't push when updating keys" + ); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const onlyKey = new BulkKeyBundle(extensionOnlyKey); + await onlyKey.generateRandom(); + keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64; + await server.installKeyRing(fxaService, keysData, saltData, 1150, { + predicate: appearsAt(1100), + }); + + let withNewKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlyKey, + ]); + dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`); + assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey); + assertKeyRingKey( + withNewKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal( + posts.length, + 1, + "ensureCanSync should push when generating a new salt" + ); + const withNewKeyRecord = await assertPostedEncryptedKeys( + fxaService, + posts[0] + ); + // We don't a priori know what the new salt is + dump(`${JSON.stringify(withNewKeyRecord)}\n`); + ok( + withNewKeyRecord.salts[extensionOnlyKey], + `ensureCanSync should generate a salt for an extension that only had a key` + ); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const newSalt = cryptoCollection.getNewSalt(); + saltData[extensionOnlySalt] = newSalt; + await server.installKeyRing(fxaService, keysData, saltData, 1250, { + predicate: appearsAt(1200), + }); + + let withOnlySaltKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlySalt, + ]); + assertKeyRingKey( + withOnlySaltKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + // We don't a priori know what the new key is + ok( + withOnlySaltKey.hasKeysFor([extensionOnlySalt]), + `ensureCanSync generated a key for an extension that only had a salt` + ); + + posts = server.getPosts(); + equal( + posts.length, + 2, + "ensureCanSync should push when generating a new key" + ); + const withNewSaltRecord = await assertPostedEncryptedKeys( + fxaService, + posts[1] + ); + equal( + withNewSaltRecord.salts[extensionOnlySalt], + newSalt, + "ensureCanSync should keep the existing salt when generating only a key" + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_handles_conflicts() { + // Syncing is done through a pull followed by a push of any merged + // changes. Accordingly, the only way to have a "true" conflict -- + // i.e. with the server rejecting a change -- is if + // someone pushes changes between our pull and our push. Ensure that + // if this happens, we still behave sensibly (keep the remote key). + const extensionId = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + // FIXME: generating salts probably shouldn't rely on a CryptoCollection + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 765, { + conflict: true, + }); + + await extensionStorageSync.cryptoCollection._clear(); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey( + collectionKeys, + extensionId, + RANDOM_KEY, + `syncing keyring should keep the server key for ${extensionId}` + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring" + ); + const failedPost = posts[0]; + assertPostedNewRecord(failedPost); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + // This key will be the one the client generated locally, so + // we don't know what its value will be + ok( + body.keys.collections[extensionId], + `decrypted failed post should have a key for ${extensionId}` + ); + notEqual( + body.keys.collections[extensionId], + RANDOM_KEY.keyPairB64, + `decrypted failed post should have a randomly-generated key for ${extensionId}` + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_handles_deleted_conflicts() { + // A keyring can be deleted, and this changes the format of the 412 + // Conflict response from the Kinto server. Make sure we handle it correctly. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function (context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.etag = 700; + await extensionStorageSync.cryptoCollection._clear(); + + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // This is the response that the Kinto server return when the + // keyring has been deleted. + server.addRecord({ + collectionId: "storage-sync-crypto", + conflict: true, + transient: true, + data: null, + etag: 765, + }); + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "syncing keyring should have tried to post a keyring twice" + ); + // The first post got a conflict. + const failedPost = posts[0]; + assertPostedUpdatedRecord(failedPost, 700); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + + deepEqual( + body.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted failed post should have the key for ${extensionId}` + ); + + // The second post was after the wipe, and succeeded. + const afterWipePost = posts[1]; + assertPostedNewRecord(afterWipePost); + let afterWipeBody = await assertPostedEncryptedKeys( + fxaService, + afterWipePost + ); + + deepEqual( + afterWipeBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + } + ); + }); +}); + +add_task(async function ensureCanSync_handles_flushes() { + // See Bug 1359879 and Bug 1350088. One of the ways that 1359879 presents is + // as 1350088. This seems to be the symptom that results when the user had + // two devices, one of which was not syncing at the time the keyring was + // lost. Ensure we can recover for these users as well. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function (context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.etag = 700; + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // last_modified is new, but there is no data. + server.etag = 800; + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring once" + ); + + const post = posts[0]; + assertPostedNewRecord(post); + let postBody = await assertPostedEncryptedKeys(fxaService, post); + + deepEqual( + postBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + } + ); + }); +}); + +add_task(async function checkSyncKeyRing_reuploads_keys() { + // Verify that when keys are present, they are reuploaded with the + // new kbHash when we call touchKeys(). + const extensionId = uuid(); + let extensionKey, extensionSalt; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + server.installCollection("storage-sync-crypto"); + server.etag = 765; + + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should return a keyring that has a key for ${extensionId}` + ); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + equal( + server.getPosts().length, + 1, + "generating a key that doesn't exist on the server should post it" + ); + const body = await assertPostedEncryptedKeys( + fxaService, + server.getPosts()[0] + ); + extensionSalt = body.salts[extensionId]; + } + ); + + // The user changes their password. This is their new kbHash, with + // the last character changed. + const newUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + let postedKeys; + await withSignedInUser( + newUser, + async function (extensionStorageSync, fxaService) { + await extensionStorageSync.checkSyncKeyRing(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "when kBHash changes, checkSyncKeyRing should post the keyring reencrypted with the new kBHash" + ); + postedKeys = posts[1]; + assertPostedUpdatedRecord(postedKeys, 765); + + let body = await assertPostedEncryptedKeys(fxaService, postedKeys); + deepEqual( + body.keys.collections[extensionId], + extensionKey, + `the posted keyring should have the same key for ${extensionId} as the old one` + ); + deepEqual( + body.salts[extensionId], + extensionSalt, + `the posted keyring should have the same salt for ${extensionId} as the old one` + ); + } + ); + + // Verify that with the old kBHash, we can't decrypt the record. + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + let error; + try { + await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + postedKeys.body.data + ); + } catch (e) { + error = e; + } + ok(error, "decrypting the keyring with the old kBHash should fail"); + ok( + Utils.isHMACMismatch(error) || + KeyRingEncryptionRemoteTransformer.isOutdatedKB(error), + "decrypting the keyring with the old kBHash should throw an HMAC mismatch" + ); + } + ); + }); +}); + +add_task(async function checkSyncKeyRing_overwrites_on_conflict() { + // If there is already a record on the server that was encrypted + // with a different kbHash, we wipe the server, clear sync state, and + // overwrite it with our keys. + const extensionId = uuid(); + let extensionKey; + await withSyncContext(async function (context) { + await withServer(async function (server) { + // The old device has this kbHash, which is very similar to the + // current kbHash but with the last character changed. + const oldUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + server.installDeleteBucket(); + await withSignedInUser( + oldUser, + async function (extensionStorageSync, fxaService) { + await server.installKeyRing(fxaService, {}, {}, 765); + } + ); + + // Now we have this new user with a different kbHash. + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + // This will try to sync, notice that the record is + // undecryptable, and clear the server. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring with a key for ${extensionId}` + ); + extensionKey = + collectionKeys.keyForCollection(extensionId).keyPairB64; + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal(posts.length, 1, "new keyring should have been uploaded"); + const postedKeys = posts[0]; + // The POST was to an empty server, so etag shouldn't be respected + equal( + postedKeys.headers.Authorization, + "Bearer some-access-token", + "keyring upload should be authorized" + ); + equal( + postedKeys.headers["If-None-Match"], + "*", + "keyring upload should be to empty Kinto server" + ); + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring upload should be to keyring path" + ); + + let body = await new KeyRingEncryptionRemoteTransformer( + fxaService + ).decode(postedKeys.body.data); + ok(body.uuid, "new keyring should have a UUID"); + equal(typeof body.uuid, "string", "keyring UUIDs should be strings"); + notEqual( + body.uuid, + "abcd", + "new keyring should not have the same UUID as previous keyring" + ); + ok(body.keys, "new keyring should have a keys attribute"); + ok(body.keys.default, "new keyring should have a default key"); + // We should keep the extension key that was in our uploaded version. + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "ensureCanSync should have returned keyring with the same key that was uploaded" + ); + + // This should be a no-op; the keys were uploaded as part of ensurekeysfor + await extensionStorageSync.checkSyncKeyRing(); + equal( + server.getPosts().length, + 1, + "checkSyncKeyRing should not need to post keys after they were reuploaded" + ); + } + ); + }); + }); +}); + +add_task(async function checkSyncKeyRing_flushes_on_uuid_change() { + // If we can decrypt the record, but the UUID has changed, that + // means another client has wiped the server and reuploaded a + // keyring, so reset sync state and reupload everything. + const extensionId = uuid(); + const extension = { id: extensionId }; + await withSyncContext(async function (context) { + await withServer(async function (server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const transformer = new KeyRingEncryptionRemoteTransformer( + fxaService + ); + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to get access to keys and salt. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring that has a key for ${extensionId}` + ); + const extensionKey = + collectionKeys.keyForCollection(extensionId).keyPairB64; + + // Set something to make sure that it gets re-uploaded when + // uuid changes. + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "should have posted a new keyring and an extension datum" + ); + const postedKeys = posts[0]; + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "should have posted keyring to /keys" + ); + + let body = await transformer.decode(postedKeys.body.data); + ok(body.uuid, "keyring should have a UUID"); + ok(body.keys, "keyring should have a keys attribute"); + ok(body.keys.default, "keyring should have a default key"); + ok( + body.salts[extensionId], + `keyring should have a salt for ${extensionId}` + ); + const extensionSalt = body.salts[extensionId]; + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "new keyring should have the same key that we uploaded" + ); + + // Another client comes along and replaces the UUID. + // In real life, this would mean changing the keys too, but + // this test verifies that just changing the UUID is enough. + const newKeyRingData = Object.assign({}, body, { + uuid: "abcd", + // Technically, last_modified should be served outside the + // object, but the transformer will pass it through in + // either direction, so this is OK. + last_modified: 765, + }); + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId: "storage-sync-crypto", + data: newKeyRingData, + predicate: appearsAt(800), + }); + + // Fake adding another extension just so that the keyring will + // really get synced. + const newExtension = uuid(); + const newKeyRing = await extensionStorageSync.ensureCanSync([ + newExtension, + ]); + + // This should have detected the UUID change and flushed everything. + // The keyring should, however, be the same, since we just + // changed the UUID of the previously POSTed one. + deepEqual( + newKeyRing.keyForCollection(extensionId).keyPairB64, + extensionKey, + "ensureCanSync should have pulled down a new keyring with the same keys" + ); + + // Syncing should reupload the data for the extension. + await extensionStorageSync.syncAll(); + posts = server.getPosts(); + equal( + posts.length, + 4, + "should have posted keyring for new extension and reuploaded extension data" + ); + + const finalKeyRingPost = posts[2]; + const reuploadedPost = posts[3]; + + equal( + finalKeyRingPost.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring for new extension should have been posted to /keys" + ); + let finalKeyRing = await transformer.decode( + finalKeyRingPost.body.data + ); + equal( + finalKeyRing.uuid, + "abcd", + "newly uploaded keyring should preserve UUID from replacement keyring" + ); + deepEqual( + finalKeyRing.salts[extensionId], + extensionSalt, + "newly uploaded keyring should preserve salts from existing salts" + ); + + // Confirm that the data got reuploaded + let reuploadedData = await assertExtensionRecord( + fxaService, + reuploadedPost, + extension, + "my-key" + ); + equal( + reuploadedData.data, + 5, + "extension data should have a data attribute corresponding to the extension data value" + ); + } + ); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_changes() { + const extensionId = defaultExtensionId; + const extension = defaultExtension; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 6 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "ExtensionStorageSync.get() returns value updated from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 6, newValue: 7 } }); + } + ); + }); +}); + +// Tests that an enabled extension which have been synced before it is going +// to be synced on ExtensionStorageSync.syncAll even if there is no active +// context that is currently using the API. +add_task(async function test_storage_sync_on_no_active_context() { + const extensionId = "sync@mochi.test"; + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: extensionId } }, + }, + files: { + "ext-page.html": `<!DOCTYPE html> + <html> + <head> + <script src="ext-page.js"></script> + </head> + </html> + `, + "ext-page.js": function () { + const { browser } = this; + browser.test.onMessage.addListener(async msg => { + if (msg === "get-sync-data") { + browser.test.sendMessage( + "get-sync-data:done", + await browser.storage.sync.get(["remote-key"]) + ); + } + }); + }, + }, + }); + + await extension.startup(); + + await withServer(async server => { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + + server.etag = 1000; + await extensionStorageSync.syncAll(); + } + ); + }); + + const extPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/ext-page.html`, + { extension } + ); + + await extension.sendMessage("get-sync-data"); + const res = await extension.awaitMessage("get-sync-data:done"); + Assert.deepEqual(res, { "remote-key": 6 }, "Got the expected sync data"); + + await extPage.close(); + + await extension.unload(); +}); + +add_task(async function test_storage_sync_pushes_changes() { + // FIXME: This test relies on the fact that previous tests pushed + // keys and salts for the default extension ID + const extension = defaultExtension; + const extensionId = defaultExtensionId; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + // install this AFTER we set the key to 5... + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const localValue = ( + await extensionStorageSync.get(extension, "my-key", context) + )["my-key"]; + equal( + localValue, + 5, + "pushing an ExtensionStorageSync value shouldn't change local value" + ); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + + let posts = server.getPosts(); + // FIXME: Keys were pushed in a previous test + equal( + posts.length, + 1, + "pushing a value should cause a post to the server" + ); + const post = posts[0]; + assertPostedNewRecord(post); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a value should have a path corresponding to its id" + ); + + const encrypted = post.body.data; + ok( + encrypted.ciphertext, + "pushing a value should post an encrypted record" + ); + ok( + !encrypted.data, + "pushing a value should not have any plaintext data" + ); + equal( + encrypted.id, + hashedId, + "pushing a value should use a kinto-friendly record ID" + ); + + const record = await assertExtensionRecord( + fxaService, + post, + extension, + "my-key" + ); + equal( + record.data, + 5, + "when decrypted, a pushed value should have a data field corresponding to its storage.sync value" + ); + equal( + record.id, + "key-my_2D_key", + "when decrypted, a pushed value should have an id field corresponding to its record ID" + ); + + equal( + calls.length, + 0, + "pushing a value shouldn't call the on-changed listener" + ); + + await extensionStorageSync.set(extension, { "my-key": 6 }, context); + await extensionStorageSync.syncAll(); + + // Doesn't push keys because keys were pushed by a previous test. + posts = server.getPosts(); + equal(posts.length, 2, "updating a value should trigger another push"); + const updatePost = posts[1]; + assertPostedUpdatedRecord(updatePost, 1000); + equal( + updatePost.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing an updated value should go to the same path" + ); + + const updateEncrypted = updatePost.body.data; + ok( + updateEncrypted.ciphertext, + "pushing an updated value should still be encrypted" + ); + ok( + !updateEncrypted.data, + "pushing an updated value should not have any plaintext visible" + ); + equal( + updateEncrypted.id, + hashedId, + "pushing an updated value should maintain the same ID" + ); + } + ); + }); +}); + +add_task(async function test_storage_sync_retries_failed_auth() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + // Put a remote record just to verify that eventually we succeeded + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + // This is a typical response from a production stack if your + // bearer token is bad. + server.rejectNextAuthWith( + '{"code": 401, "errno": 104, "error": "Unauthorized", "message": "Please authenticate yourself to use this endpoint"}' + ); + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 1, "an auth was failed"); + + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + // Try again with an emptier JSON body to make sure this still + // works with a less-cooperative server. + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + server.etag = 1000; + // Need to write a JSON response. + // kinto.js 9.0.2 doesn't throw unless there's json. + // See https://github.com/Kinto/kinto-http.js/issues/192. + server.rejectNextAuthWith("{}"); + + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 2, "an auth was failed"); + + const newRemoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + newRemoteValue, + 7, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + } + ); + }); +}); + +add_task(async function test_storage_sync_pulls_conflicts() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.set(extension, { "remote-key": 8 }, context); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal(remoteValue, 8, "locally set value overrides remote value"); + + equal(calls.length, 1, "conflicts manifest in on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 8 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "conflicts do not prevent retrieval of new values" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 8, newValue: 7 } }); + } + ); + }); +}); + +add_task(async function test_storage_sync_pulls_deletes() { + const extension = defaultExtension; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + defaultExtensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + server.clearPosts(); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + const transformer = new CollectionKeyEncryptionRemoteTransformer( + new CryptoCollection(fxaService), + await cryptoCollection.getKeyRing(), + extension.id + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-my_2D_key", + data: 6, + _status: "deleted", + }, + }); + + await extensionStorageSync.syncAll(); + const remoteValues = await extensionStorageSync.get( + extension, + "my-key", + context + ); + ok( + !remoteValues["my-key"], + "ExtensionStorageSync.get() shows value was deleted by sync" + ); + + equal( + server.getPosts().length, + 0, + "pulling the delete shouldn't cause posts" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "my-key": { oldValue: 5 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + } + ); + }); +}); + +add_task(async function test_storage_sync_pushes_deletes() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function (context, server) { + await withSignedInUser( + loggedInUser, + async function (extensionStorageSync, fxaService) { + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._clear(); + await cryptoCollection._setSalt( + extensionId, + cryptoCollection.getNewSalt() + ); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function () { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + let posts = server.getPosts(); + equal( + posts.length, + 2, + "pushing a non-deleted value should post keys and post the value to the server" + ); + + await extensionStorageSync.remove(extension, ["my-key"], context); + equal( + calls.length, + 1, + "deleting a value should call the on-changed listener" + ); + + await extensionStorageSync.syncAll(); + equal( + calls.length, + 1, + "pushing a deleted value shouldn't call the on-changed listener" + ); + + // Doesn't push keys because keys were pushed by a previous test. + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + posts = server.getPosts(); + equal(posts.length, 3, "deleting a value should trigger another push"); + const post = posts[2]; + assertPostedUpdatedRecord(post, 1000); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a deleted value should go to the same path" + ); + ok(post.method, "PUT"); + ok( + post.body.data.ciphertext, + "deleting a value should have an encrypted body" + ); + const decoded = await new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ).decode(post.body.data); + equal(decoded._status, "deleted"); + // Ideally, we'd check that decoded.deleted is not true, because + // the encrypted record shouldn't have it, but the decoder will + // add it when it sees _status == deleted + } + ); + }); +}); + +// Some sync tests shared between implementations. +add_task(test_config_flag_needed); + +add_task(test_sync_reloading_extensions_works); + +add_task(function test_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_page_storage("sync") + ); +}); + +add_task(test_storage_sync_requires_real_id); + +add_task(function test_storage_sync_with_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_storage_area_with_bytes_in_use("sync", false) + ); +}); + +add_task(function test_storage_onChanged_event_page() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_storage_change_event_page("sync") + ); +}); |