forked from mirrors/gecko-dev
421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
"use strict";
|
|
|
|
ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
|
|
ChromeUtils.import("resource://gre/modules/components-utils/FilterExpressions.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/RecipeRunner.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/CleanupManager.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/ActionSandboxManager.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/ActionsManager.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
|
|
ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
|
|
|
|
const { RemoteSettings } = ChromeUtils.import("resource://services-settings/remote-settings.js");
|
|
|
|
add_task(async function getFilterContext() {
|
|
const recipe = {id: 17, arguments: {foo: "bar"}, unrelated: false};
|
|
const context = RecipeRunner.getFilterContext(recipe);
|
|
|
|
// Test for expected properties in the filter expression context.
|
|
const expectedNormandyKeys = [
|
|
"channel",
|
|
"country",
|
|
"distribution",
|
|
"doNotTrack",
|
|
"isDefaultBrowser",
|
|
"locale",
|
|
"plugins",
|
|
"recipe",
|
|
"request_time",
|
|
"searchEngine",
|
|
"syncDesktopDevices",
|
|
"syncMobileDevices",
|
|
"syncSetup",
|
|
"syncTotalDevices",
|
|
"telemetry",
|
|
"userId",
|
|
"version",
|
|
];
|
|
for (const key of expectedNormandyKeys) {
|
|
ok(key in context.env, `env.${key} is available`);
|
|
ok(key in context.normandy, `normandy.${key} is available`);
|
|
}
|
|
Assert.deepEqual(context.normandy, context.env,
|
|
"context offers normandy as backwards-compatible alias for context.environment");
|
|
|
|
is(
|
|
context.env.recipe.id,
|
|
recipe.id,
|
|
"environment.recipe is the recipe passed to getFilterContext",
|
|
);
|
|
delete recipe.unrelated;
|
|
Assert.deepEqual(
|
|
context.env.recipe,
|
|
recipe,
|
|
"environment.recipe drops unrecognized attributes from the recipe",
|
|
);
|
|
|
|
// Filter context attributes are cached.
|
|
await SpecialPowers.pushPrefEnv({set: [["app.normandy.user_id", "some id"]]});
|
|
is(context.env.userId, "some id", "User id is read from prefs when accessed");
|
|
await SpecialPowers.pushPrefEnv({set: [["app.normandy.user_id", "real id"]]});
|
|
is(context.env.userId, "some id", "userId was cached");
|
|
});
|
|
|
|
add_task(async function checkFilter() {
|
|
const check = filter => RecipeRunner.checkFilter({filter_expression: filter});
|
|
|
|
// Errors must result in a false return value.
|
|
ok(!(await check("invalid ( + 5yntax")), "Invalid filter expressions return false");
|
|
|
|
// Non-boolean filter results result in a true return value.
|
|
ok(await check("[1, 2, 3]"), "Non-boolean filter expressions return true");
|
|
|
|
// The given recipe must be available to the filter context.
|
|
const recipe = {filter_expression: "normandy.recipe.id == 7", id: 7};
|
|
ok(await RecipeRunner.checkFilter(recipe), "The recipe is available in the filter context");
|
|
recipe.id = 4;
|
|
ok(!(await RecipeRunner.checkFilter(recipe)), "The recipe is available in the filter context");
|
|
});
|
|
|
|
decorate_task(
|
|
withStub(FilterExpressions, "eval"),
|
|
withStub(Uptake, "reportRecipe"),
|
|
async function checkFilterCanHandleExceptions(
|
|
evalStub,
|
|
reportRecipeStub,
|
|
) {
|
|
evalStub.throws("this filter was broken somehow");
|
|
const someRecipe = {id: "1", action: "action", filter_expression: "broken"};
|
|
const result = await RecipeRunner.checkFilter(someRecipe);
|
|
|
|
Assert.deepEqual(result, false, "broken filters are treated as false");
|
|
Assert.deepEqual(
|
|
reportRecipeStub.args,
|
|
[[someRecipe, Uptake.RECIPE_FILTER_BROKEN]]
|
|
);
|
|
}
|
|
);
|
|
|
|
decorate_task(
|
|
withMockNormandyApi,
|
|
withStub(ClientEnvironment, "getClientClassification"),
|
|
async function testClientClassificationCache(api, getStub) {
|
|
getStub.returns(Promise.resolve(false));
|
|
|
|
await SpecialPowers.pushPrefEnv({set: [
|
|
["app.normandy.api_url",
|
|
"https://example.com/selfsupport-dummy"],
|
|
]});
|
|
|
|
// When the experiment pref is false, eagerly call getClientClassification.
|
|
await SpecialPowers.pushPrefEnv({set: [
|
|
["app.normandy.experiments.lazy_classify", false],
|
|
]});
|
|
ok(!getStub.called, "getClientClassification hasn't been called");
|
|
await RecipeRunner.run();
|
|
ok(getStub.called, "getClientClassification was called eagerly");
|
|
|
|
// When the experiment pref is true, do not eagerly call getClientClassification.
|
|
await SpecialPowers.pushPrefEnv({set: [
|
|
["app.normandy.experiments.lazy_classify", true],
|
|
]});
|
|
getStub.reset();
|
|
ok(!getStub.called, "getClientClassification hasn't been called");
|
|
await RecipeRunner.run();
|
|
ok(!getStub.called, "getClientClassification was not called eagerly");
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Mocks RecipeRunner.loadActionSandboxManagers for testing run.
|
|
*/
|
|
async function withMockActionSandboxManagers(actions, testFunction) {
|
|
const managers = {};
|
|
for (const action of actions) {
|
|
const manager = new ActionSandboxManager("");
|
|
manager.addHold("testing");
|
|
managers[action.name] = manager;
|
|
sinon.stub(managers[action.name], "runAsyncCallback");
|
|
}
|
|
|
|
const loadActionSandboxManagers = sinon.stub(RecipeRunner, "loadActionSandboxManagers")
|
|
.resolves(managers);
|
|
await testFunction(managers);
|
|
loadActionSandboxManagers.restore();
|
|
|
|
for (const manager of Object.values(managers)) {
|
|
manager.removeHold("testing");
|
|
await manager.isNuked();
|
|
}
|
|
}
|
|
|
|
decorate_task(
|
|
withStub(Uptake, "reportRunner"),
|
|
withStub(NormandyApi, "fetchRecipes"),
|
|
withStub(ActionsManager.prototype, "fetchRemoteActions"),
|
|
withStub(ActionsManager.prototype, "preExecution"),
|
|
withStub(ActionsManager.prototype, "runRecipe"),
|
|
withStub(ActionsManager.prototype, "finalize"),
|
|
withStub(Uptake, "reportRecipe"),
|
|
async function testRun(
|
|
reportRunnerStub,
|
|
fetchRecipesStub,
|
|
fetchRemoteActionsStub,
|
|
preExecutionStub,
|
|
runRecipeStub,
|
|
finalizeStub,
|
|
reportRecipeStub,
|
|
) {
|
|
const runRecipeReturn = Promise.resolve();
|
|
const runRecipeReturnThen = sinon.spy(runRecipeReturn, "then");
|
|
runRecipeStub.returns(runRecipeReturn);
|
|
|
|
const matchRecipe = {id: "match", action: "matchAction", filter_expression: "true"};
|
|
const noMatchRecipe = {id: "noMatch", action: "noMatchAction", filter_expression: "false"};
|
|
const missingRecipe = {id: "missing", action: "missingAction", filter_expression: "true"};
|
|
fetchRecipesStub.callsFake(async () => [matchRecipe, noMatchRecipe, missingRecipe]);
|
|
|
|
await RecipeRunner.run();
|
|
|
|
ok(fetchRemoteActionsStub.calledOnce, "remote actions should be fetched");
|
|
ok(preExecutionStub.calledOnce, "pre-execution hooks should be run");
|
|
Assert.deepEqual(
|
|
runRecipeStub.args,
|
|
[[matchRecipe], [missingRecipe]],
|
|
"recipe with matching filters should be executed",
|
|
);
|
|
ok(runRecipeReturnThen.called, "the run method should be used asyncronously");
|
|
|
|
// Test uptake reporting
|
|
Assert.deepEqual(
|
|
reportRunnerStub.args,
|
|
[[Uptake.RUNNER_SUCCESS]],
|
|
"RecipeRunner should report uptake telemetry",
|
|
);
|
|
Assert.deepEqual(
|
|
reportRecipeStub.args,
|
|
[[noMatchRecipe, Uptake.RECIPE_DIDNT_MATCH_FILTER]],
|
|
"Filtered-out recipes should be reported",
|
|
);
|
|
}
|
|
);
|
|
|
|
decorate_task(
|
|
withPrefEnv({
|
|
set: [
|
|
["features.normandy-remote-settings.enabled", true],
|
|
],
|
|
}),
|
|
withStub(ActionsManager.prototype, "runRecipe"),
|
|
withStub(ActionsManager.prototype, "fetchRemoteActions"),
|
|
withStub(ActionsManager.prototype, "finalize"),
|
|
withStub(Uptake, "reportRecipe"),
|
|
async function testReadFromRemoteSettings(
|
|
runRecipeStub,
|
|
fetchRemoteActionsStub,
|
|
finalizeStub,
|
|
reportRecipeStub,
|
|
) {
|
|
const matchRecipe = { id: "match", action: "matchAction", filter_expression: "true", _status: "synced", enabled: true };
|
|
const noMatchRecipe = { id: "noMatch", action: "noMatchAction", filter_expression: "false", _status: "synced", enabled: true };
|
|
const missingRecipe = { id: "missing", action: "missingAction", filter_expression: "true", _status: "synced", enabled: true };
|
|
|
|
const rsCollection = await RecipeRunner._remoteSettingsClientForTesting.openCollection();
|
|
await rsCollection.create(matchRecipe, { synced: true });
|
|
await rsCollection.create(noMatchRecipe, { synced: true });
|
|
await rsCollection.create(missingRecipe, { synced: true });
|
|
await rsCollection.db.saveLastModified(42);
|
|
rsCollection.db.close();
|
|
|
|
await RecipeRunner.run();
|
|
|
|
Assert.deepEqual(
|
|
runRecipeStub.args,
|
|
[[matchRecipe], [missingRecipe]],
|
|
"recipe with matching filters should be executed",
|
|
);
|
|
Assert.deepEqual(
|
|
reportRecipeStub.args,
|
|
[[noMatchRecipe, Uptake.RECIPE_DIDNT_MATCH_FILTER]],
|
|
"Filtered-out recipes should be reported",
|
|
);
|
|
}
|
|
);
|
|
|
|
decorate_task(
|
|
withMockNormandyApi,
|
|
async function testRunFetchFail(mockApi) {
|
|
const reportRunner = sinon.stub(Uptake, "reportRunner");
|
|
|
|
const action = {name: "action"};
|
|
mockApi.actions = [action];
|
|
mockApi.fetchRecipes.rejects(new Error("Signature not valid"));
|
|
|
|
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
|
const manager = managers.action;
|
|
await RecipeRunner.run();
|
|
|
|
// If the recipe fetch failed, do not run anything.
|
|
sinon.assert.notCalled(manager.runAsyncCallback);
|
|
sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SERVER_ERROR);
|
|
|
|
// Test that network errors report a specific uptake error
|
|
reportRunner.reset();
|
|
mockApi.fetchRecipes.rejects(new Error("NetworkError: The system was down"));
|
|
await RecipeRunner.run();
|
|
sinon.assert.calledWith(reportRunner, Uptake.RUNNER_NETWORK_ERROR);
|
|
|
|
// Test that signature issues report a specific uptake error
|
|
reportRunner.reset();
|
|
mockApi.fetchRecipes.rejects(new NormandyApi.InvalidSignatureError("Signature fail"));
|
|
await RecipeRunner.run();
|
|
sinon.assert.calledWith(reportRunner, Uptake.RUNNER_INVALID_SIGNATURE);
|
|
});
|
|
|
|
reportRunner.restore();
|
|
}
|
|
);
|
|
|
|
// test init() in dev mode
|
|
decorate_task(
|
|
withPrefEnv({
|
|
set: [
|
|
["datareporting.healthreport.uploadEnabled", true], // telemetry enabled
|
|
["app.normandy.dev_mode", true],
|
|
["app.normandy.first_run", false],
|
|
],
|
|
}),
|
|
withStub(RecipeRunner, "run"),
|
|
withStub(RecipeRunner, "registerTimer"),
|
|
async function testInitDevMode(runStub, registerTimerStub, updateRunIntervalStub) {
|
|
await RecipeRunner.init();
|
|
ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
|
|
ok(registerTimerStub.called, "RecipeRunner.init registers a timer");
|
|
}
|
|
);
|
|
|
|
// Test init() during normal operation
|
|
decorate_task(
|
|
withPrefEnv({
|
|
set: [
|
|
["datareporting.healthreport.uploadEnabled", true], // telemetry enabled
|
|
["app.normandy.dev_mode", false],
|
|
["app.normandy.first_run", false],
|
|
],
|
|
}),
|
|
withStub(RecipeRunner, "run"),
|
|
withStub(RecipeRunner, "registerTimer"),
|
|
async function testInit(runStub, registerTimerStub) {
|
|
await RecipeRunner.init();
|
|
ok(!runStub.called, "RecipeRunner.run is called immediately when not in dev mode or first run");
|
|
ok(registerTimerStub.called, "RecipeRunner.init registers a timer");
|
|
}
|
|
);
|
|
|
|
// Test init() first run
|
|
decorate_task(
|
|
withPrefEnv({
|
|
set: [
|
|
["datareporting.healthreport.uploadEnabled", true], // telemetry enabled
|
|
["app.normandy.dev_mode", false],
|
|
["app.normandy.first_run", true],
|
|
["app.normandy.api_url", "https://example.com"],
|
|
],
|
|
}),
|
|
withStub(RecipeRunner, "run"),
|
|
withStub(RecipeRunner, "registerTimer"),
|
|
withStub(RecipeRunner, "watchPrefs"),
|
|
async function testInitFirstRun(runStub, registerTimerStub, watchPrefsStub) {
|
|
await RecipeRunner.init();
|
|
ok(runStub.called, "RecipeRunner.run is called immediately on first run");
|
|
ok(
|
|
!Services.prefs.getBoolPref("app.normandy.first_run"),
|
|
"On first run, the first run pref is set to false"
|
|
);
|
|
ok(registerTimerStub.called, "RecipeRunner.registerTimer registers a timer");
|
|
|
|
// RecipeRunner.init() sets this pref to false, but SpecialPowers
|
|
// relies on the preferences it manages to actually change when it
|
|
// tries to change them. Settings this back to true here allows
|
|
// that to happen. Not doing this causes popPrefEnv to hang forever.
|
|
Services.prefs.setBoolPref("app.normandy.first_run", true);
|
|
}
|
|
);
|
|
|
|
// Test that prefs are watched correctly
|
|
decorate_task(
|
|
withPrefEnv({
|
|
set: [
|
|
["datareporting.healthreport.uploadEnabled", true], // telemetry enabled
|
|
["app.normandy.dev_mode", false],
|
|
["app.normandy.first_run", false],
|
|
["app.normandy.enabled", true],
|
|
["app.normandy.api_url", "https://example.com"], // starts with "https://"
|
|
],
|
|
}),
|
|
withStub(RecipeRunner, "run"),
|
|
withStub(RecipeRunner, "enable"),
|
|
withStub(RecipeRunner, "disable"),
|
|
withStub(CleanupManager, "addCleanupHandler"),
|
|
withStub(AddonStudies, "stop"),
|
|
|
|
async function testPrefWatching(runStub, enableStub, disableStub, addCleanupHandlerStub, stopStub) {
|
|
await RecipeRunner.init();
|
|
is(enableStub.callCount, 1, "Enable should be called initially");
|
|
is(disableStub.callCount, 0, "Disable should not be called initially");
|
|
|
|
await SpecialPowers.pushPrefEnv({ set: [["app.normandy.enabled", false]] });
|
|
is(enableStub.callCount, 1, "Enable should not be called again");
|
|
is(disableStub.callCount, 1, "RecipeRunner should disable when Shield is disabled");
|
|
|
|
await SpecialPowers.pushPrefEnv({ set: [["app.normandy.enabled", true]] });
|
|
is(enableStub.callCount, 2, "RecipeRunner should re-enable when Shield is enabled");
|
|
is(disableStub.callCount, 1, "Disable should not be called again");
|
|
|
|
await SpecialPowers.pushPrefEnv({ set: [["app.normandy.api_url", "http://example.com"]] }); // does not start with https://
|
|
is(enableStub.callCount, 2, "Enable should not be called again");
|
|
is(disableStub.callCount, 2, "RecipeRunner should disable when an invalid api url is given");
|
|
|
|
await SpecialPowers.pushPrefEnv({ set: [["app.normandy.api_url", "https://example.com"]] }); // ends with https://
|
|
is(enableStub.callCount, 3, "RecipeRunner should re-enable when a valid api url is given");
|
|
is(disableStub.callCount, 2, "Disable should not be called again");
|
|
|
|
await SpecialPowers.pushPrefEnv({ set: [["datareporting.healthreport.uploadEnabled", false]] });
|
|
is(enableStub.callCount, 3, "Enable should not be called again");
|
|
is(disableStub.callCount, 3, "RecipeRunner should disable when telemetry is disabled");
|
|
|
|
await SpecialPowers.pushPrefEnv({ set: [["datareporting.healthreport.uploadEnabled", true]] });
|
|
is(enableStub.callCount, 4, "RecipeRunner should re-enable when telemetry is enabled");
|
|
is(disableStub.callCount, 3, "Disable should not be called again");
|
|
|
|
is(runStub.callCount, 0, "RecipeRunner.run should not be called during this test");
|
|
}
|
|
);
|
|
|
|
// Test that enable and disable are idempotent
|
|
decorate_task(
|
|
withStub(RecipeRunner, "registerTimer"),
|
|
withStub(RecipeRunner, "unregisterTimer"),
|
|
|
|
async function testPrefWatching(registerTimerStub, unregisterTimerStub) {
|
|
const originalEnabled = RecipeRunner.enabled;
|
|
|
|
try {
|
|
RecipeRunner.enabled = false;
|
|
RecipeRunner.enable();
|
|
RecipeRunner.enable();
|
|
RecipeRunner.enable();
|
|
is(registerTimerStub.callCount, 1, "Enable should be idempotent");
|
|
|
|
RecipeRunner.enabled = true;
|
|
RecipeRunner.disable();
|
|
RecipeRunner.disable();
|
|
RecipeRunner.disable();
|
|
is(registerTimerStub.callCount, 1, "Disable should be idempotent");
|
|
} finally {
|
|
RecipeRunner.enabled = originalEnabled;
|
|
}
|
|
}
|
|
);
|