зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1606883 - Remove legacy method of fetching Normandy recipes directly from the server r=leplatrem
Differential Revision: https://phabricator.services.mozilla.com/D58651 --HG-- extra : moz-landing-system : lando
This commit is contained in:
Родитель
7b485ae170
Коммит
1469d0cf85
|
@ -208,6 +208,12 @@ var whitelist = [
|
|||
isFromDevTools: true,
|
||||
},
|
||||
{ file: "chrome://devtools/skin/images/next.svg", isFromDevTools: true },
|
||||
// Feature gates are available but not used yet - Bug 1479127
|
||||
{ file: "resource://featuregates/FeatureGate.jsm" },
|
||||
{
|
||||
file: "resource://featuregates/FeatureGateImplementation.jsm",
|
||||
},
|
||||
{ file: "resource://featuregates/feature_definitions.json" },
|
||||
// Bug 1526672
|
||||
{
|
||||
file: "resource://app/localization/en-US/browser/touchbar/touchbar.ftl",
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
[normandy-remote-settings]
|
||||
title = "Normandy: Remote Settings transport"
|
||||
description = """
|
||||
When enabled, the Normandy client will fetch recipes from Remote Settings \
|
||||
instead of directly from the Normandy API."""
|
||||
# there are currently no feature gates
|
||||
[demo-feature]
|
||||
title = "Demo Feature"
|
||||
description = "A no-op feature to demo the feature gate system."
|
||||
restart-required = false
|
||||
preference = "foo.bar.baz"
|
||||
type = "boolean"
|
||||
bug-numbers = [1519276]
|
||||
bug-numbers = [1479127]
|
||||
is-public = true
|
||||
default-value = true
|
||||
default-value = false
|
||||
|
|
|
@ -13,13 +13,11 @@ and then executes them.
|
|||
|
||||
.. note::
|
||||
|
||||
Originally, the recipes were fetched from the `recipe server`_, but in `Bug 1513854`_
|
||||
Previously, the recipes were fetched from the `recipe server`_, but in `Bug 1513854`_
|
||||
the source was changed to *Remote Settings*. The cryptographic signatures are verified
|
||||
at the *Remote Settings* level (integrity) and at the *Normandy* level
|
||||
(authenticity of publisher).
|
||||
|
||||
The source can still be controlled by :ref:`Feature Gates <components/featuregates>`.
|
||||
|
||||
.. _recipe server: https://github.com/mozilla/normandy/
|
||||
.. _Bug 1513854: https://bugzilla.mozilla.org/show_bug.cgi?id=1513854
|
||||
|
||||
|
|
|
@ -8,9 +8,6 @@ const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|||
const { XPCOMUtils } = ChromeUtils.import(
|
||||
"resource://gre/modules/XPCOMUtils.jsm"
|
||||
);
|
||||
const { LogManager } = ChromeUtils.import(
|
||||
"resource://normandy/lib/LogManager.jsm"
|
||||
);
|
||||
|
||||
ChromeUtils.defineModuleGetter(
|
||||
this,
|
||||
|
@ -25,7 +22,6 @@ XPCOMUtils.defineLazyGlobalGetters(this, [
|
|||
|
||||
var EXPORTED_SYMBOLS = ["NormandyApi"];
|
||||
|
||||
const log = LogManager.getLogger("normandy-api");
|
||||
const prefs = Services.prefs.getBranch("app.normandy.");
|
||||
|
||||
let indexPromise = null;
|
||||
|
@ -37,31 +33,18 @@ var NormandyApi = {
|
|||
indexPromise = null;
|
||||
},
|
||||
|
||||
apiCall(method, endpoint, data = {}) {
|
||||
get(endpoint, data) {
|
||||
const url = new URL(endpoint);
|
||||
method = method.toLowerCase();
|
||||
|
||||
let body = undefined;
|
||||
if (data) {
|
||||
if (method === "get") {
|
||||
for (const key of Object.keys(data)) {
|
||||
url.searchParams.set(key, data[key]);
|
||||
}
|
||||
} else if (method === "post") {
|
||||
body = JSON.stringify(data);
|
||||
for (const key of Object.keys(data)) {
|
||||
url.searchParams.set(key, data[key]);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = { Accept: "application/json" };
|
||||
return fetch(url.href, { method, body, headers, credentials: "omit" });
|
||||
},
|
||||
|
||||
get(endpoint, data) {
|
||||
return this.apiCall("get", endpoint, data);
|
||||
},
|
||||
|
||||
post(endpoint, data) {
|
||||
return this.apiCall("post", endpoint, data);
|
||||
return fetch(url.href, {
|
||||
method: "get",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "omit",
|
||||
});
|
||||
},
|
||||
|
||||
absolutify(url) {
|
||||
|
@ -92,33 +75,6 @@ var NormandyApi = {
|
|||
return this.absolutify(url);
|
||||
},
|
||||
|
||||
async fetchSignedObjects(type, filters) {
|
||||
const signedObjectsUrl = await this.getApiUrl(`${type}-signed`);
|
||||
const objectsResponse = await this.get(signedObjectsUrl, filters);
|
||||
const rawText = await objectsResponse.text();
|
||||
const objectsWithSigs = JSON.parse(rawText);
|
||||
|
||||
return Promise.all(
|
||||
objectsWithSigs.map(async item => {
|
||||
// Check that the rawtext (the object and the signature)
|
||||
// includes the CanonicalJSON version of the object. This isn't
|
||||
// strictly needed, but it is a great benefit for debugging
|
||||
// signature problems.
|
||||
const object = item[type];
|
||||
const serialized = CanonicalJSON.stringify(object);
|
||||
if (!rawText.includes(serialized)) {
|
||||
log.debug(rawText, serialized);
|
||||
throw new NormandyApi.InvalidSignatureError(
|
||||
`Canonical ${type} serialization does not match!`
|
||||
);
|
||||
}
|
||||
// Verify content signature using cryptography (will throw if fails).
|
||||
await this.verifyObjectSignature(serialized, item.signature, type);
|
||||
return object;
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify content signature, by serializing the specified `object` as
|
||||
* canonical JSON, and using the Normandy signer verifier to check that
|
||||
|
@ -178,15 +134,6 @@ var NormandyApi = {
|
|||
return clientData;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch an array of available actions from the server.
|
||||
* @resolves {Array}
|
||||
*/
|
||||
async fetchRecipes() {
|
||||
const filters = { enabled: true, only_baseline_capabilities: false };
|
||||
return this.fetchSignedObjects("recipe", filters);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch details for an extension from the server.
|
||||
* @param extensionId {integer} The ID of the extension to look up
|
||||
|
|
|
@ -21,7 +21,6 @@ XPCOMUtils.defineLazyServiceGetter(
|
|||
|
||||
XPCOMUtils.defineLazyModuleGetters(this, {
|
||||
RemoteSettings: "resource://services-settings/remote-settings.js",
|
||||
FeatureGate: "resource://featuregates/FeatureGate.jsm",
|
||||
Storage: "resource://normandy/lib/Storage.jsm",
|
||||
FilterExpressions:
|
||||
"resource://gre/modules/components-utils/FilterExpressions.jsm",
|
||||
|
@ -65,10 +64,6 @@ XPCOMUtils.defineLazyGetter(this, "gRemoteSettingsClient", () => {
|
|||
});
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "gRemoteSettingsGate", () => {
|
||||
return FeatureGate.fromId("normandy-remote-settings");
|
||||
});
|
||||
|
||||
/**
|
||||
* cacheProxy returns an object Proxy that will memoize properties of the target.
|
||||
*/
|
||||
|
@ -100,7 +95,7 @@ var RecipeRunner = {
|
|||
|
||||
this.checkPrefs(); // sets this.enabled
|
||||
this.watchPrefs();
|
||||
await this.setUpRemoteSettings();
|
||||
this.setUpRemoteSettings();
|
||||
|
||||
// Here "first run" means the first run this profile has ever done. This
|
||||
// preference is set to true at the end of this function, and never reset to
|
||||
|
@ -131,10 +126,7 @@ var RecipeRunner = {
|
|||
// This is not needed for the first run case, because remote settings
|
||||
// already handles empty collections well.
|
||||
if (devMode) {
|
||||
let remoteSettingsGate = await gRemoteSettingsGate;
|
||||
if (await remoteSettingsGate.isEnabled()) {
|
||||
await gRemoteSettingsClient.sync();
|
||||
}
|
||||
await gRemoteSettingsClient.sync();
|
||||
}
|
||||
let trigger;
|
||||
if (devMode) {
|
||||
|
@ -252,70 +244,52 @@ var RecipeRunner = {
|
|||
timerManager.unregisterTimer(TIMER_NAME);
|
||||
},
|
||||
|
||||
async setUpRemoteSettings() {
|
||||
const remoteSettingsGate = await gRemoteSettingsGate;
|
||||
if (await remoteSettingsGate.isEnabled()) {
|
||||
this.attachRemoteSettings();
|
||||
setUpRemoteSettings() {
|
||||
if (this._alreadySetUpRemoteSettings) {
|
||||
return;
|
||||
}
|
||||
const observer = {
|
||||
onEnable: this.attachRemoteSettings.bind(this),
|
||||
onDisable: this.detachRemoteSettings.bind(this),
|
||||
};
|
||||
remoteSettingsGate.addObserver(observer);
|
||||
CleanupManager.addCleanupHandler(() =>
|
||||
remoteSettingsGate.removeObserver(observer)
|
||||
);
|
||||
},
|
||||
this._alreadySetUpRemoteSettings = true;
|
||||
|
||||
attachRemoteSettings() {
|
||||
this.loadFromRemoteSettings = true;
|
||||
if (!this._onSync) {
|
||||
this._onSync = async () => {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay the Normandy run by a random amount, determined by preference.
|
||||
// This helps alleviate server load, since we don't have a thundering
|
||||
// herd of users trying to update all at once.
|
||||
if (this._syncSkewTimeout) {
|
||||
clearTimeout(this._syncSkewTimeout);
|
||||
}
|
||||
let minSkewSec = 1; // this is primarily is to avoid race conditions in tests
|
||||
let maxSkewSec = Services.prefs.getIntPref(ONSYNC_SKEW_SEC_PREF, 0);
|
||||
if (maxSkewSec >= minSkewSec) {
|
||||
let skewMillis =
|
||||
(minSkewSec + Math.random() * (maxSkewSec - minSkewSec)) * 1000;
|
||||
log.debug(
|
||||
`Delaying on-sync Normandy run for ${Math.floor(
|
||||
skewMillis / 1000
|
||||
)} seconds`
|
||||
);
|
||||
this._syncSkewTimeout = setTimeout(
|
||||
() => this.run({ trigger: "sync" }),
|
||||
skewMillis
|
||||
);
|
||||
} else {
|
||||
log.debug(`Not skewing on-sync Normandy run`);
|
||||
await this.run({ trigger: "sync" });
|
||||
}
|
||||
};
|
||||
|
||||
gRemoteSettingsClient.on("sync", this._onSync);
|
||||
this._onSync = this.onSync.bind(this);
|
||||
}
|
||||
gRemoteSettingsClient.on("sync", this._onSync);
|
||||
|
||||
CleanupManager.addCleanupHandler(() => {
|
||||
gRemoteSettingsClient.off("sync", this._onSync);
|
||||
this._alreadySetUpRemoteSettings = false;
|
||||
});
|
||||
},
|
||||
|
||||
detachRemoteSettings() {
|
||||
this.loadFromRemoteSettings = false;
|
||||
if (this._onSync) {
|
||||
// Ignore if no event listener was setup or was already removed (ie. pref changed while enabled).
|
||||
gRemoteSettingsClient.off("sync", this._onSync);
|
||||
this._onSync = null;
|
||||
/** Called when our Remote Settings collection is updated */
|
||||
async onSync() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay the Normandy run by a random amount, determined by preference.
|
||||
// This helps alleviate server load, since we don't have a thundering
|
||||
// herd of users trying to update all at once.
|
||||
if (this._syncSkewTimeout) {
|
||||
clearTimeout(this._syncSkewTimeout);
|
||||
this._syncSkewTimeout = null;
|
||||
}
|
||||
let minSkewSec = 1; // this is primarily is to avoid race conditions in tests
|
||||
let maxSkewSec = Services.prefs.getIntPref(ONSYNC_SKEW_SEC_PREF, 0);
|
||||
if (maxSkewSec >= minSkewSec) {
|
||||
let skewMillis =
|
||||
(minSkewSec + Math.random() * (maxSkewSec - minSkewSec)) * 1000;
|
||||
log.debug(
|
||||
`Delaying on-sync Normandy run for ${Math.floor(
|
||||
skewMillis / 1000
|
||||
)} seconds`
|
||||
);
|
||||
this._syncSkewTimeout = setTimeout(
|
||||
() => this.run({ trigger: "sync" }),
|
||||
skewMillis
|
||||
);
|
||||
} else {
|
||||
log.debug(`Not skewing on-sync Normandy run`);
|
||||
await this.run({ trigger: "sync" });
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -357,8 +331,6 @@ var RecipeRunner = {
|
|||
try {
|
||||
recipesToRun = await this.loadRecipes();
|
||||
} catch (e) {
|
||||
// Either we failed at fetching the recipes from server (legacy),
|
||||
// or the recipes signature verification failed.
|
||||
let status = Uptake.RUNNER_SERVER_ERROR;
|
||||
if (/NetworkError/.test(e)) {
|
||||
status = Uptake.RUNNER_NETWORK_ERROR;
|
||||
|
@ -399,44 +371,17 @@ var RecipeRunner = {
|
|||
* Return the list of recipes to run, filtered for the current environment.
|
||||
*/
|
||||
async loadRecipes() {
|
||||
// If RemoteSettings is enabled, we read the list of recipes from there.
|
||||
// The recipe filtering is done via the provided callback (see `gRemoteSettingsClient`).
|
||||
if (this.loadFromRemoteSettings) {
|
||||
// First, fetch recipes that should run on this client.
|
||||
const entries = await gRemoteSettingsClient.get();
|
||||
// Then, verify the signature of each recipe. It will throw if invalid.
|
||||
return Promise.all(
|
||||
entries.map(async ({ recipe, signature }) => {
|
||||
await NormandyApi.verifyObjectSignature(recipe, signature, "recipe");
|
||||
return recipe;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Obtain the recipes from the Normandy server (legacy).
|
||||
let recipes;
|
||||
try {
|
||||
recipes = await NormandyApi.fetchRecipes();
|
||||
log.debug(
|
||||
`Fetched ${recipes.length} recipes from the server: ` +
|
||||
recipes.map(r => r.name).join(", ")
|
||||
);
|
||||
} catch (e) {
|
||||
const apiUrl = Services.prefs.getCharPref(API_URL_PREF);
|
||||
log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Check if each recipe should be run, according to `shouldRunRecipe`. This
|
||||
// can't be a simple call to `Array.filter` because checking if a recipe
|
||||
// should run is an async operation.
|
||||
const recipesToRun = [];
|
||||
for (const recipe of recipes) {
|
||||
if (await this.shouldRunRecipe(recipe)) {
|
||||
recipesToRun.push(recipe);
|
||||
}
|
||||
}
|
||||
return recipesToRun;
|
||||
// Fetch recipes that should run on this client. Then, verify the signature
|
||||
// of each recipe. The recipe filtering is done implicitly by the callback
|
||||
// provided to `gRemoteSettingsClient`.
|
||||
const entries = await gRemoteSettingsClient.get();
|
||||
return Promise.all(
|
||||
entries.map(async ({ recipe, signature }) => {
|
||||
// this will throw if the signature is invalid
|
||||
await NormandyApi.verifyObjectSignature(recipe, signature, "recipe");
|
||||
return recipe;
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
getFilterContext(recipe) {
|
||||
|
|
|
@ -223,76 +223,6 @@ decorate_task(
|
|||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["features.normandy-remote-settings.enabled", false]],
|
||||
}),
|
||||
withStub(Uptake, "reportRunner"),
|
||||
withStub(NormandyApi, "fetchRecipes"),
|
||||
withStub(ActionsManager.prototype, "runRecipe"),
|
||||
withStub(ActionsManager.prototype, "finalize"),
|
||||
withStub(Uptake, "reportRecipe"),
|
||||
async function testRun(
|
||||
reportRunnerStub,
|
||||
fetchRecipesStub,
|
||||
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();
|
||||
|
||||
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(RecipeRunner, "getCapabilities"),
|
||||
async function test_run_includesCapabilities(getCapabilitiesStub) {
|
||||
const rsCollection = await RecipeRunner._remoteSettingsClientForTesting.openCollection();
|
||||
|
@ -313,17 +243,12 @@ decorate_task(
|
|||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["features.normandy-remote-settings.enabled", true]],
|
||||
}),
|
||||
withStub(NormandyApi, "verifyObjectSignature"),
|
||||
withSpy(NormandyApi, "fetchRecipes"),
|
||||
withStub(ActionsManager.prototype, "runRecipe"),
|
||||
withStub(ActionsManager.prototype, "finalize"),
|
||||
withStub(Uptake, "reportRecipe"),
|
||||
async function testReadFromRemoteSettings(
|
||||
verifyObjectSignatureStub,
|
||||
fetchRecipesSpy,
|
||||
runRecipeStub,
|
||||
finalizeStub,
|
||||
reportRecipeStub
|
||||
|
@ -379,15 +304,10 @@ decorate_task(
|
|||
[[noMatchRecipe, Uptake.RECIPE_DIDNT_MATCH_FILTER]],
|
||||
"Filtered-out recipes should be reported"
|
||||
);
|
||||
|
||||
ok(fetchRecipesSpy.notCalled, "fetchRecipes should not be called");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["features.normandy-remote-settings.enabled", true]],
|
||||
}),
|
||||
withStub(NormandyApi, "verifyObjectSignature"),
|
||||
withStub(ActionsManager.prototype, "runRecipe"),
|
||||
withStub(RecipeRunner, "getCapabilities"),
|
||||
|
@ -433,9 +353,6 @@ decorate_task(
|
|||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["features.normandy-remote-settings.enabled", true]],
|
||||
}),
|
||||
withStub(ActionsManager.prototype, "runRecipe"),
|
||||
withStub(NormandyApi, "get"),
|
||||
withStub(Uptake, "reportRunner"),
|
||||
|
@ -477,40 +394,6 @@ decorate_task(
|
|||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["features.normandy-remote-settings.enabled", false]],
|
||||
}),
|
||||
withMockNormandyApi,
|
||||
async function testRunFetchFail(mockApi) {
|
||||
const reportRunner = sinon.stub(Uptake, "reportRunner");
|
||||
mockApi.fetchRecipes.rejects(new Error("Signature not valid"));
|
||||
|
||||
await RecipeRunner.run();
|
||||
|
||||
// If the recipe fetch failed, report a server error
|
||||
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() during normal operation
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
|
@ -715,72 +598,45 @@ decorate_task(
|
|||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
["features.normandy-remote-settings.enabled", false],
|
||||
["app.normandy.onsync_skew_sec", 0],
|
||||
],
|
||||
set: [["app.normandy.onsync_skew_sec", 0]],
|
||||
}),
|
||||
withStub(RecipeRunner, "run"),
|
||||
async function testRunOnSyncRemoteSettings(runStub) {
|
||||
const rsClient = RecipeRunner._remoteSettingsClientForTesting;
|
||||
await RecipeRunner.init();
|
||||
ok(
|
||||
RecipeRunner._alreadySetUpRemoteSettings,
|
||||
"remote settings should be set up in the runner"
|
||||
);
|
||||
|
||||
// Runner disabled + pref off.
|
||||
// Runner disabled
|
||||
RecipeRunner.disable();
|
||||
await rsClient.emit("sync", {});
|
||||
ok(!runStub.called, "run() should not be called if disabled");
|
||||
runStub.reset();
|
||||
|
||||
// Runner enabled + pref off.
|
||||
// Runner enabled
|
||||
RecipeRunner.enable();
|
||||
await rsClient.emit("sync", {});
|
||||
ok(!runStub.called, "run() should not be called if pref not set");
|
||||
ok(runStub.called, "run() should be called if enabled");
|
||||
runStub.reset();
|
||||
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["features.normandy-remote-settings.enabled", true]],
|
||||
});
|
||||
|
||||
// Runner enabled + pref on.
|
||||
await rsClient.emit("sync", {});
|
||||
ok(runStub.called, "run() should be called if pref is set");
|
||||
runStub.reset();
|
||||
|
||||
// Runner disabled + pref on.
|
||||
// Runner disabled
|
||||
RecipeRunner.disable();
|
||||
await rsClient.emit("sync", {});
|
||||
ok(!runStub.called, "run() should not be called if disabled with pref set");
|
||||
ok(!runStub.called, "run() should not be called if disabled");
|
||||
runStub.reset();
|
||||
|
||||
// Runner re-enabled + pref on.
|
||||
// Runner re-enabled
|
||||
RecipeRunner.enable();
|
||||
await rsClient.emit("sync", {});
|
||||
ok(
|
||||
runStub.called,
|
||||
"run() should be called at most once if runner is re-enabled"
|
||||
);
|
||||
runStub.reset();
|
||||
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["features.normandy-remote-settings.enabled", false]],
|
||||
});
|
||||
|
||||
// Runner enabled + pref off.
|
||||
await rsClient.emit("sync", {});
|
||||
ok(!runStub.called, "run() should not be called if pref is unset");
|
||||
runStub.reset();
|
||||
|
||||
// Runner disabled + pref off.
|
||||
RecipeRunner.disable();
|
||||
await rsClient.emit("sync", {});
|
||||
ok(!runStub.called, "run() should still not be called if disabled");
|
||||
RecipeRunner.enable();
|
||||
ok(runStub.called, "run() should be called if runner is re-enabled");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
["features.normandy-remote-settings.enabled", true],
|
||||
["app.normandy.onsync_skew_sec", 600], // 10 minutes, much longer than the test will take to run
|
||||
],
|
||||
}),
|
||||
|
@ -816,7 +672,6 @@ decorate_task(
|
|||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
["features.normandy-remote-settings.enabled", true],
|
||||
// Enable update timer logs.
|
||||
["app.update.log", true],
|
||||
["app.normandy.onsync_skew_sec", 0],
|
||||
|
@ -849,6 +704,8 @@ decorate_task(
|
|||
RecipeRunner.unregisterTimer();
|
||||
RecipeRunner.registerTimer();
|
||||
|
||||
is(loadRecipesStub.callCount, 0, "run() shouldn't have run yet");
|
||||
|
||||
// Simulate timer notification.
|
||||
const service = Cc["@mozilla.org/updates/timer-manager;1"].getService(
|
||||
Ci.nsITimerCallback
|
||||
|
@ -865,21 +722,21 @@ decorate_task(
|
|||
await endPromise; // will timeout if run() not called.
|
||||
const timerLatency = Date.now() - startTime;
|
||||
|
||||
is(loadRecipesStub.callCount, 1, "run() should be called from timer");
|
||||
|
||||
// Run once from sync event.
|
||||
const rsClient = RecipeRunner._remoteSettingsClientForTesting;
|
||||
await rsClient.emit("sync", {}); // waits for listeners to run.
|
||||
|
||||
is(loadRecipesStub.callCount, 2, "run() should be called from sync");
|
||||
|
||||
// Run timer again.
|
||||
service.notify(newTimer());
|
||||
// Wait at least as long as the latency we had above. Ten times as a margin.
|
||||
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
|
||||
await new Promise(resolve => setTimeout(resolve, timerLatency * 10));
|
||||
|
||||
is(
|
||||
loadRecipesStub.callCount,
|
||||
2,
|
||||
"run() does not run again from timer after sync"
|
||||
);
|
||||
is(loadRecipesStub.callCount, 2, "run() does not run again from timer");
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -131,9 +131,6 @@ this.withMockNormandyApi = function(testFunction) {
|
|||
};
|
||||
|
||||
// Use callsFake instead of resolves so that the current values in mockApi are used.
|
||||
mockApi.fetchRecipes = sinon
|
||||
.stub(NormandyApi, "fetchRecipes")
|
||||
.callsFake(async () => mockApi.recipes);
|
||||
mockApi.fetchExtensionDetails = sinon
|
||||
.stub(NormandyApi, "fetchExtensionDetails")
|
||||
.callsFake(async extensionId => {
|
||||
|
@ -147,7 +144,6 @@ this.withMockNormandyApi = function(testFunction) {
|
|||
try {
|
||||
await testFunction(...args, mockApi);
|
||||
} finally {
|
||||
mockApi.fetchRecipes.restore();
|
||||
mockApi.fetchExtensionDetails.restore();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -81,85 +81,42 @@ add_task(
|
|||
})
|
||||
);
|
||||
|
||||
add_task(
|
||||
withMockApiServer(async function test_fetchRecipes() {
|
||||
const recipes = await NormandyApi.fetchRecipes();
|
||||
equal(recipes.length, 1);
|
||||
equal(recipes[0].name, "system-addon-test");
|
||||
})
|
||||
);
|
||||
|
||||
add_task(async function test_fetchSignedObjects_canonical_mismatch() {
|
||||
const getApiUrl = sinon.stub(NormandyApi, "getApiUrl");
|
||||
|
||||
// The object is non-canonical (it has whitespace, properties are out of order)
|
||||
const response = new MockResponse(`[
|
||||
{
|
||||
"object": {"b": 1, "a": 2},
|
||||
"signature": {"signature": "", "x5u": ""}
|
||||
}
|
||||
]`);
|
||||
const get = sinon.stub(NormandyApi, "get").resolves(response);
|
||||
|
||||
try {
|
||||
await NormandyApi.fetchSignedObjects("object");
|
||||
ok(false, "fetchSignedObjects did not throw for canonical JSON mismatch");
|
||||
} catch (err) {
|
||||
ok(
|
||||
err instanceof NormandyApi.InvalidSignatureError,
|
||||
"Error is an InvalidSignatureError"
|
||||
);
|
||||
ok(/Canonical/.test(err), "Error is due to canonical JSON mismatch");
|
||||
}
|
||||
|
||||
getApiUrl.restore();
|
||||
get.restore();
|
||||
});
|
||||
|
||||
// Test validation errors due to validation throwing an exception (e.g. when
|
||||
// parameters passed to validation are malformed).
|
||||
add_task(
|
||||
withMockApiServer(async function test_fetchSignedObjects_validation_error() {
|
||||
const getApiUrl = sinon
|
||||
.stub(NormandyApi, "getApiUrl")
|
||||
.resolves("http://localhost/object/");
|
||||
|
||||
// Mock two URLs: object and the x5u
|
||||
const get = sinon.stub(NormandyApi, "get").callsFake(async url => {
|
||||
if (url.endsWith("object/")) {
|
||||
return new MockResponse(
|
||||
CanonicalJSON.stringify([
|
||||
{
|
||||
object: { a: 1, b: 2 },
|
||||
signature: {
|
||||
signature: "invalidsignature",
|
||||
x5u: "http://localhost/x5u/",
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
} else if (url.endsWith("x5u/")) {
|
||||
withMockApiServer(
|
||||
async function test_validateSignedObject_validation_error() {
|
||||
// Mock the x5u URL
|
||||
const getStub = sinon.stub(NormandyApi, "get").callsFake(async url => {
|
||||
ok(url.endsWith("x5u/"), "the only request should be to fetch the x5u");
|
||||
return new MockResponse("certchain");
|
||||
});
|
||||
|
||||
const signedObject = { a: 1, b: 2 };
|
||||
const signature = {
|
||||
signature: "invalidsignature",
|
||||
x5u: "http://localhost/x5u/",
|
||||
};
|
||||
|
||||
// Validation should fail due to a malformed x5u and signature.
|
||||
try {
|
||||
await NormandyApi.verifyObjectSignature(
|
||||
signedObject,
|
||||
signature,
|
||||
"object"
|
||||
);
|
||||
ok(false, "validateSignedObject did not throw for a validation error");
|
||||
} catch (err) {
|
||||
ok(
|
||||
err instanceof NormandyApi.InvalidSignatureError,
|
||||
"Error is an InvalidSignatureError"
|
||||
);
|
||||
ok(/signature/.test(err), "Error is due to a validation error");
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// Validation should fail due to a malformed x5u and signature.
|
||||
try {
|
||||
await NormandyApi.fetchSignedObjects("object");
|
||||
ok(false, "fetchSignedObjects did not throw for a validation error");
|
||||
} catch (err) {
|
||||
ok(
|
||||
err instanceof NormandyApi.InvalidSignatureError,
|
||||
"Error is an InvalidSignatureError"
|
||||
);
|
||||
ok(/signature/.test(err), "Error is due to a validation error");
|
||||
getStub.restore();
|
||||
}
|
||||
|
||||
getApiUrl.restore();
|
||||
get.restore();
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Test validation errors due to validation returning false (e.g. when parameters
|
||||
|
@ -170,10 +127,20 @@ const invalidSignatureServer = makeMockApiServer(
|
|||
add_task(
|
||||
withServer(
|
||||
invalidSignatureServer,
|
||||
async function test_fetchSignedObjects_invalid_signature() {
|
||||
async function test_verifySignedObject_invalid_signature() {
|
||||
// Get the test recipe and signature from the mock server.
|
||||
const recipesUrl = await NormandyApi.getApiUrl("recipe-signed");
|
||||
const recipeResponse = await NormandyApi.get(recipesUrl);
|
||||
const recipes = await recipeResponse.json();
|
||||
equal(recipes.length, 1, "Test data has one recipe");
|
||||
const [{ recipe, signature }] = recipes;
|
||||
|
||||
try {
|
||||
await NormandyApi.fetchSignedObjects("recipe");
|
||||
ok(false, "fetchSignedObjects did not throw for an invalid signature");
|
||||
await NormandyApi.verifyObjectSignature(recipe, signature, "recipe");
|
||||
ok(
|
||||
false,
|
||||
"verifyObjectSignature did not throw for an invalid signature"
|
||||
);
|
||||
} catch (err) {
|
||||
ok(
|
||||
err instanceof NormandyApi.InvalidSignatureError,
|
||||
|
@ -244,22 +211,6 @@ add_task(
|
|||
})
|
||||
);
|
||||
|
||||
add_task(
|
||||
withScriptServer("query_server.sjs", async function test_postData(serverUrl) {
|
||||
// Test that NormandyApi can POST JSON-formatted data to the test server.
|
||||
const response = await NormandyApi.post(serverUrl, {
|
||||
foo: "bar",
|
||||
baz: "biff",
|
||||
});
|
||||
const data = await response.json();
|
||||
Assert.deepEqual(
|
||||
data,
|
||||
{ queryString: {}, body: { foo: "bar", baz: "biff" } },
|
||||
"NormandyApi sent an incorrect query string."
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Test that no credentials are sent, even if the cookie store contains them.
|
||||
add_task(
|
||||
withScriptServer("cookie_server.sjs", async function test_sendsNoCredentials(
|
||||
|
|
Загрузка…
Ссылка в новой задаче