/* 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.importESModule(
"resource://gre/modules/FxAccountsCommon.sys.mjs"
);
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 => {
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 = () => {
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 = () => {
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._generateAndPersistEncryptedCommandKey = async () => {
generateEncryptedKeysCalled =
true;
};
await sendTab.getEncryptedCommandKeys();
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._generateAndPersistEncryptedCommandKey = async () => {
generateEncryptedKeysCalled =
true;
};
await sendTab.getEncryptedCommandKeys();
Assert.ok(!generateEncryptedKeysCalled);
});