708 lines
18 KiB
JavaScript
708 lines
18 KiB
JavaScript
/* 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);
|
|
});
|