Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* Any copyright is dedicated to the Public Domain.
"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: {
},
};
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 " +
)
);
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",
});
}
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",
};
};
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);
});