summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/tests/xpcshell/test_commands.js
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts/tests/xpcshell/test_commands.js')
-rw-r--r--services/fxaccounts/tests/xpcshell/test_commands.js708
1 files changed, 708 insertions, 0 deletions
diff --git a/services/fxaccounts/tests/xpcshell/test_commands.js b/services/fxaccounts/tests/xpcshell/test_commands.js
new file mode 100644
index 0000000000..c1e67ff105
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_commands.js
@@ -0,0 +1,708 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsCommands, SendTab } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommands.sys.mjs"
+);
+
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+const { COMMAND_SENDTAB, COMMAND_SENDTAB_TAIL } = ChromeUtils.import(
+ "resource://gre/modules/FxAccountsCommon.js"
+);
+
+class TelemetryMock {
+ constructor() {
+ this._events = [];
+ this._uuid_counter = 0;
+ }
+
+ recordEvent(object, method, value, extra = undefined) {
+ this._events.push({ object, method, value, extra });
+ }
+
+ generateFlowID() {
+ this._uuid_counter += 1;
+ return this._uuid_counter.toString();
+ }
+
+ sanitizeDeviceId(id) {
+ return id + "-san";
+ }
+}
+
+function FxaInternalMock() {
+ return {
+ telemetry: new TelemetryMock(),
+ };
+}
+
+function MockFxAccountsClient() {
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+add_task(async function test_sendtab_isDeviceCompatible() {
+ const sendTab = new SendTab(null, null);
+ let device = { name: "My device" };
+ Assert.ok(!sendTab.isDeviceCompatible(device));
+ device = { name: "My device", availableCommands: {} };
+ Assert.ok(!sendTab.isDeviceCompatible(device));
+ device = {
+ name: "My device",
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "payload",
+ },
+ };
+ Assert.ok(sendTab.isDeviceCompatible(device));
+});
+
+add_task(async function test_sendtab_send() {
+ const commands = {
+ invoke: sinon.spy((cmd, device, payload) => {
+ if (device.name == "Device 1") {
+ throw new Error("Invoke error!");
+ }
+ Assert.equal(payload.encrypted, "encryptedpayload");
+ }),
+ };
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(commands, fxai);
+ sendTab._encrypt = (bytes, device) => {
+ if (device.name == "Device 2") {
+ throw new Error("Encrypt error!");
+ }
+ return "encryptedpayload";
+ };
+ const to = [
+ { name: "Device 1" },
+ { name: "Device 2" },
+ { id: "dev3", name: "Device 3" },
+ ];
+ // although we are sending to 3 devices, only 1 is successful - so there's
+ // only 1 streamID we care about. However, we've created IDs even for the
+ // failing items - so it's "4"
+ const expectedTelemetryStreamID = "4";
+ const tab = { title: "Foo", url: "https://foo.bar/" };
+ const report = await sendTab.send(to, tab);
+ Assert.equal(report.succeeded.length, 1);
+ Assert.equal(report.failed.length, 2);
+ Assert.equal(report.succeeded[0].name, "Device 3");
+ Assert.equal(report.failed[0].device.name, "Device 1");
+ Assert.equal(report.failed[0].error.message, "Invoke error!");
+ Assert.equal(report.failed[1].device.name, "Device 2");
+ Assert.equal(report.failed[1].error.message, "Encrypt error!");
+ Assert.ok(commands.invoke.calledTwice);
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "dev3-san",
+ extra: { flowID: "1", streamID: expectedTelemetryStreamID },
+ },
+ ]);
+});
+
+add_task(async function test_sendtab_send_rate_limit() {
+ const rateLimitReject = {
+ code: 429,
+ retryAfter: 5,
+ retryAfterLocalized: "retry after 5 seconds",
+ };
+ const fxAccounts = {
+ fxAccountsClient: new MockFxAccountsClient(),
+ getUserAccountData() {
+ return {};
+ },
+ telemetry: new TelemetryMock(),
+ };
+ let rejected = false;
+ let invoked = 0;
+ fxAccounts.fxAccountsClient.invokeCommand = async function invokeCommand() {
+ invoked++;
+ Assert.ok(invoked <= 2, "only called twice and not more");
+ if (rejected) {
+ return {};
+ }
+ rejected = true;
+ return Promise.reject(rateLimitReject);
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const sendTab = new SendTab(commands, fxAccounts);
+ sendTab._encrypt = () => "encryptedpayload";
+
+ const tab = { title: "Foo", url: "https://foo.bar/" };
+ let report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 0);
+ Assert.equal(report.failed.length, 1);
+ Assert.equal(report.failed[0].error, rateLimitReject);
+
+ report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 0);
+ Assert.equal(report.failed.length, 1);
+ Assert.ok(
+ report.failed[0].error.message.includes(
+ "Invoke for " +
+ "https://identity.mozilla.com/cmd/open-uri is rate-limited"
+ )
+ );
+
+ commands._invokeRateLimitExpiry = Date.now() - 1000;
+ report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 1);
+ Assert.equal(report.failed.length, 0);
+});
+
+add_task(async function test_sendtab_receive() {
+ // We are testing 'receive' here, but might as well go through 'send'
+ // to package the data and for additional testing...
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(commands, fxai);
+ sendTab._encrypt = (bytes, device) => {
+ return bytes;
+ };
+ sendTab._decrypt = bytes => {
+ return bytes;
+ };
+ const tab = { title: "tab title", url: "http://example.com" };
+ const to = [{ id: "devid", name: "The Device" }];
+ const reason = "push";
+
+ await sendTab.send(to, tab);
+ Assert.equal(commands._invokes.length, 1);
+
+ for (let { cmd, device, payload } of commands._invokes) {
+ Assert.equal(cmd, COMMAND_SENDTAB);
+ // Older Firefoxes would send a plaintext flowID in the top-level payload.
+ // Test that we sensibly ignore it.
+ Assert.ok(!payload.hasOwnProperty("flowID"));
+ // change it - ensure we still get what we expect in telemetry later.
+ payload.flowID = "ignore-me";
+ Assert.deepEqual(await sendTab.handle(device.id, payload, reason), {
+ title: "tab title",
+ uri: "http://example.com",
+ });
+ }
+
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "devid-san",
+ extra: { flowID: "1", streamID: "2" },
+ },
+ {
+ object: "command-received",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "devid-san",
+ extra: { flowID: "1", streamID: "2", reason },
+ },
+ ]);
+});
+
+// Test that a client which only sends the flowID in the envelope and not in the
+// encrypted body gets recorded without the flowID.
+add_task(async function test_sendtab_receive_old_client() {
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(null, fxai);
+ sendTab._decrypt = bytes => {
+ return bytes;
+ };
+ const data = { entries: [{ title: "title", url: "url" }] };
+ // No 'flowID' in the encrypted payload, no 'streamID' anywhere.
+ const payload = {
+ flowID: "flow-id",
+ encrypted: new TextEncoder().encode(JSON.stringify(data)),
+ };
+ const reason = "push";
+ await sendTab.handle("sender-id", payload, reason);
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-received",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "sender-id-san",
+ // deepEqual doesn't ignore undefined, but our telemetry code and
+ // JSON.stringify() do...
+ extra: { flowID: undefined, streamID: undefined, reason },
+ },
+ ]);
+});
+
+add_task(function test_commands_getReason() {
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const testCases = [
+ {
+ receivedIndex: 0,
+ currentIndex: 0,
+ expectedReason: "poll",
+ message: "should return reason 'poll'",
+ },
+ {
+ receivedIndex: 7,
+ currentIndex: 3,
+ expectedReason: "push-missed",
+ message: "should return reason 'push-missed'",
+ },
+ {
+ receivedIndex: 2,
+ currentIndex: 8,
+ expectedReason: "push",
+ message: "should return reason 'push'",
+ },
+ ];
+ for (const tc of testCases) {
+ const reason = commands._getReason(tc.receivedIndex, tc.currentIndex);
+ Assert.equal(reason, tc.expectedReason, tc.message);
+ }
+});
+
+add_task(async function test_commands_pollDeviceCommands_push() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+
+ // Local state.
+ const pushIndexReceived = 11;
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 10,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_push_already_fetched() {
+ // Local state.
+ const pushIndexReceived = 12;
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 12,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").never();
+ mockCommands.expects("_handleCommands").never();
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_commands_handleCommands() {
+ // This test ensures that `_getReason` is being called by
+ // `_handleCommands` with the expected parameters.
+ const pushIndexReceived = 12;
+ const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485";
+ const remoteMessageIndex = 8;
+ const remoteMessages = [
+ {
+ index: remoteMessageIndex,
+ data: {
+ command: COMMAND_SENDTAB,
+ payload: {
+ encrypted: {},
+ },
+ sender: senderID,
+ },
+ },
+ ];
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ commands.sendTab.handle = (sender, data, reason) => {
+ return {
+ title: "testTitle",
+ uri: "https://testURI",
+ };
+ };
+ commands._fxai.device = {
+ refreshDeviceList: () => {},
+ recentDeviceList: [
+ {
+ id: senderID,
+ },
+ ],
+ };
+ const mockCommands = sinon.mock(commands);
+ mockCommands
+ .expects("_getReason")
+ .once()
+ .withExactArgs(pushIndexReceived, remoteMessageIndex);
+ mockCommands.expects("_notifyFxATabsReceived").once();
+ await commands._handleCommands(remoteMessages, pushIndexReceived);
+ mockCommands.verify();
+});
+
+add_task(async function test_commands_handleCommands_invalid_tab() {
+ // This test ensures that `_getReason` is being called by
+ // `_handleCommands` with the expected parameters.
+ const pushIndexReceived = 12;
+ const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485";
+ const remoteMessageIndex = 8;
+ const remoteMessages = [
+ {
+ index: remoteMessageIndex,
+ data: {
+ command: COMMAND_SENDTAB,
+ payload: {
+ encrypted: {},
+ },
+ sender: senderID,
+ },
+ },
+ ];
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ commands.sendTab.handle = (sender, data, reason) => {
+ return {
+ title: "badUriTab",
+ uri: "file://path/to/pdf",
+ };
+ };
+ commands._fxai.device = {
+ refreshDeviceList: () => {},
+ recentDeviceList: [
+ {
+ id: senderID,
+ },
+ ],
+ };
+ const mockCommands = sinon.mock(commands);
+ mockCommands
+ .expects("_getReason")
+ .once()
+ .withExactArgs(pushIndexReceived, remoteMessageIndex);
+ // We shouldn't have tried to open a tab with an invalid uri
+ mockCommands.expects("_notifyFxATabsReceived").never();
+
+ await commands._handleCommands(remoteMessages, pushIndexReceived);
+ mockCommands.verify();
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_push_local_state_empty() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+
+ // Local state.
+ const pushIndexReceived = 11;
+ const accountState = {
+ data: {
+ device: {},
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_commands_pollDeviceCommands_scheduled_local() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+ const pushIndexReceived = 0;
+ // Local state.
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 10,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands();
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_scheduled_local_state_empty() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+ const pushIndexReceived = 0;
+ // Local state.
+ const accountState = {
+ data: {
+ device: {},
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(0).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands();
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_send_tab_keys_regenerated_if_lost() {
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ // Local state.
+ const accountState = {
+ data: {
+ // Since the device object has no
+ // sendTabKeys, it will recover
+ // when we attempt to get the
+ // encryptedSendTabKeys
+ device: {
+ lastCommandIndex: 10,
+ },
+ encryptedSendTabKeys: "keys",
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ async getUserAccountData(data) {
+ return accountState.getUserAccountData(data);
+ },
+ telemetry: new TelemetryMock(),
+ };
+ const sendTab = new SendTab(commands, fxAccounts);
+ let generateEncryptedKeysCalled = false;
+ sendTab._generateAndPersistEncryptedSendTabKey = async () => {
+ generateEncryptedKeysCalled = true;
+ };
+ await sendTab.getEncryptedSendTabKeys();
+ Assert.ok(generateEncryptedKeysCalled);
+});
+
+add_task(async function test_send_tab_keys_are_not_regenerated_if_not_lost() {
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ // Local state.
+ const accountState = {
+ data: {
+ // Since the device object has
+ // sendTabKeys, it will not try
+ // to regenerate them
+ // when we attempt to get the
+ // encryptedSendTabKeys
+ device: {
+ lastCommandIndex: 10,
+ sendTabKeys: "keys",
+ },
+ encryptedSendTabKeys: "encrypted-keys",
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ async getUserAccountData(data) {
+ return accountState.getUserAccountData(data);
+ },
+ telemetry: new TelemetryMock(),
+ };
+ const sendTab = new SendTab(commands, fxAccounts);
+ let generateEncryptedKeysCalled = false;
+ sendTab._generateAndPersistEncryptedSendTabKey = async () => {
+ generateEncryptedKeysCalled = true;
+ };
+ await sendTab.getEncryptedSendTabKeys();
+ Assert.ok(!generateEncryptedKeysCalled);
+});