diff --git a/.circleci/config.yml b/.circleci/config.yml index 00c1edf..e4296e1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,6 +64,10 @@ jobs: path: "test-addon/dist" destination: "test-addon/dist" + - run: + name: Import and build the Pioneer opt-in add-on + command: npm run import-pioneer-opt-in + # Needs signed add-on to work on branded releases #- run: # name: Test with Firefox Release diff --git a/bin/import-pioneer-opt-in.sh b/bin/import-pioneer-opt-in.sh new file mode 100755 index 0000000..cdf3e8b --- /dev/null +++ b/bin/import-pioneer-opt-in.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +echo "$@" + +set -eu +#set -o xtrace + +BASE_DIR="$(dirname "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)")" + +# download and build xpi for https://github.com/mozilla/pioneer-opt-in.git +if [ ! -d "pioneer-opt-in" ]; then + git clone https://github.com/mozilla/pioneer-opt-in.git +fi +cd pioneer-opt-in +bin/make-xpi.sh . +cd - + +echo +echo "SUCCESS: pioneer-opt-in xpi available at pioneer-opt-in/pioneer-opt-in.xpi" +echo diff --git a/examples/small-study/src/.eslintrc.js b/examples/small-study/src/.eslintrc.js index 7e9f4bb..8ef4351 100644 --- a/examples/small-study/src/.eslintrc.js +++ b/examples/small-study/src/.eslintrc.js @@ -7,4 +7,7 @@ module.exports = { es6: true, webextensions: true, }, + rules: { + "no-console": "off", + }, }; diff --git a/examples/small-study/src/studySetup.js b/examples/small-study/src/studySetup.js index 4d8a125..9b6385f 100644 --- a/examples/small-study/src/studySetup.js +++ b/examples/small-study/src/studySetup.js @@ -22,15 +22,17 @@ const baseStudySetup = { // used for activeExperiments tagging (telemetryEnvironment.setActiveExperiment) activeExperimentName: browser.runtime.id, - // uses shield sampling and telemetry semantics. Future: will support "pioneer" + // use either "shield" or "pioneer" telemetry semantics and data pipelines studyType: "shield", // telemetry telemetry: { - // default false. Actually send pings. + // Actually submit the pings to Telemetry. [default if omitted: false] send: true, - // Marks pings with testing=true. Set flag to `true` before final release + // Marks pings with testing=true. Set flag to `true` for pings are meant to be seen by analysts [default if omitted: false] removeTestingFlag: false, + // Keep an internal telemetry archive. Useful for verifying payloads of Pioneer studies without risking actually sending any unencrypted payloads [default if omitted: false] + internalTelemetryArchive: false, }, // endings with urls @@ -99,10 +101,11 @@ const baseStudySetup = { * * This implementation caches in local storage to speed up second run. * + * @param {object} studySetup A complete study setup object * @returns {Promise} answer An boolean answer about whether the user should be * allowed to enroll in the study */ -async function cachingFirstRunShouldAllowEnroll() { +async function cachingFirstRunShouldAllowEnroll(studySetup) { // Cached answer. Used on 2nd run let allowed = await browser.storage.local.get("allowedEnrollOnFirstRun"); if (allowed.allowedEnrollOnFirstRun === true) return true; @@ -113,7 +116,13 @@ async function cachingFirstRunShouldAllowEnroll() { */ // could have other reasons to be eligible, such add-ons, prefs - allowed = true; + const dataPermissions = await browser.study.getDataPermissions(); + if (studySetup.studyType === "shield") { + allowed = dataPermissions.shield; + } + if (studySetup.studyType === "pioneer") { + allowed = dataPermissions.pioneer; + } // cache the answer await browser.storage.local.set({ allowedEnrollOnFirstRun: allowed }); @@ -129,7 +138,7 @@ async function getStudySetup() { // shallow copy const studySetup = Object.assign({}, baseStudySetup); - studySetup.allowEnroll = await cachingFirstRunShouldAllowEnroll(); + studySetup.allowEnroll = await cachingFirstRunShouldAllowEnroll(studySetup); const testingOverrides = await browser.study.getTestingOverrides(); studySetup.testing = { @@ -137,5 +146,6 @@ async function getStudySetup() { firstRunTimestamp: testingOverrides.firstRunTimestamp, expired: testingOverrides.expired, }; + return studySetup; } diff --git a/package-lock.json b/package-lock.json index 85d8043..c928fb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5484,6 +5484,11 @@ "integrity": "sha1-LPn7rkbYB0/Ba33gBxyO/rykc6Y=", "dev": true }, + "jose-jwe-jws": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/jose-jwe-jws/-/jose-jwe-jws-0.1.6.tgz", + "integrity": "sha512-sZgrf5u15NmfiRAy3TAzQkIDsbXJ4zE11/rRTbQlGNFFy57u3B98U9JwH864+WfWM8BuJPqyCDDfDJJXUdWhiw==" + }, "js-select": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/js-select/-/js-select-0.6.0.tgz", diff --git a/package.json b/package.json index f533484..00f2f57 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "5.1.1", "author": "Mozilla", "bin": { - "copyStudyUtils": "bin/copyStudyUtils.js" + "copyStudyUtils": "bin/copyStudyUtils.js", + "importPioneerOptIn": "bin/import-pioneer-opt-in.sh" }, "bugs": { "url": "https://github.com/mozilla/shield-studies-addon-utils/issues" @@ -13,6 +14,7 @@ "ajv": "^6.5.0", "commander": "^2.15.1", "fs-extra": "^6.0.1", + "jose-jwe-jws": "0.1.6", "shield-study-schemas": "^0.8.3" }, "devDependencies": { @@ -41,6 +43,7 @@ }, "files": [ "bin/copyStudyUtils.js", + "bin/import-pioneer-opt-in.sh", "testUtils", "webExtensionApis/study/api.js", "webExtensionApis/study/schema.json", @@ -80,6 +83,7 @@ "generate:generateSchema:study": "cd webExtensionApis/study && yaml2json schema.yaml -p > schema.json", "generate:generateStubApi:study": "cd webExtensionApis/study && generateStubApi ./schema.json > stubApi.js", "generate:verifyWeeSchema:study": "cd webExtensionApis/study && verifyWeeSchema schema.json", + "import-pioneer-opt-in": "bin/import-pioneer-opt-in.sh", "lint": "npm-run-all lint:*", "lint:eslint": "npm run eslint", "lint:fixpack": "fixpack # cleans up package.json", @@ -87,7 +91,7 @@ "postformat": "run-p lint:fixpack eslint-fix", "prebuild": "if [ -z ${SKIPLINT} ]; then npm run lint; fi", "prepare": "export SKIPLINT=1 && fixpack && npm run build", - "pretest": "npm run build && npm run test-addon:bundle-utils && npm run test-addon:build", + "pretest": "npm run build && npm run test-addon:bundle-utils && npm run test-addon:build && npm run import-pioneer-opt-in", "pretest-addon": "npm run pretest", "small-study": "cd examples/small-study && npm run rebuild && npm start", "test": "npm run test:func", diff --git a/test-addon/src/studySetup.js b/test-addon/src/studySetup.js index 4d8a125..3a6ba9b 100644 --- a/test-addon/src/studySetup.js +++ b/test-addon/src/studySetup.js @@ -22,15 +22,17 @@ const baseStudySetup = { // used for activeExperiments tagging (telemetryEnvironment.setActiveExperiment) activeExperimentName: browser.runtime.id, - // uses shield sampling and telemetry semantics. Future: will support "pioneer" - studyType: "shield", + // use either "shield" or "pioneer" telemetry semantics and data pipelines + studyType: null, // set by internal test override below in getStudySetup() // telemetry telemetry: { - // default false. Actually send pings. + // Actually submit the pings to Telemetry. [default if omitted: false] send: true, - // Marks pings with testing=true. Set flag to `true` before final release + // Marks pings with testing=true. Set flag to `true` for pings are meant to be seen by analysts [default if omitted: false] removeTestingFlag: false, + // Keep an internal telemetry archive. Useful for verifying payloads of Pioneer studies without risking actually sending any unencrypted payloads [default if omitted: false] + internalTelemetryArchive: true, }, // endings with urls @@ -99,10 +101,11 @@ const baseStudySetup = { * * This implementation caches in local storage to speed up second run. * + * @param {object} studySetup A complete study setup object * @returns {Promise} answer An boolean answer about whether the user should be * allowed to enroll in the study */ -async function cachingFirstRunShouldAllowEnroll() { +async function cachingFirstRunShouldAllowEnroll(studySetup) { // Cached answer. Used on 2nd run let allowed = await browser.storage.local.get("allowedEnrollOnFirstRun"); if (allowed.allowedEnrollOnFirstRun === true) return true; @@ -113,7 +116,13 @@ async function cachingFirstRunShouldAllowEnroll() { */ // could have other reasons to be eligible, such add-ons, prefs - allowed = true; + const dataPermissions = await browser.study.getDataPermissions(); + if (studySetup.studyType === "shield") { + allowed = dataPermissions.shield; + } + if (studySetup.studyType === "pioneer") { + allowed = dataPermissions.pioneer; + } // cache the answer await browser.storage.local.set({ allowedEnrollOnFirstRun: allowed }); @@ -129,7 +138,11 @@ async function getStudySetup() { // shallow copy const studySetup = Object.assign({}, baseStudySetup); - studySetup.allowEnroll = await cachingFirstRunShouldAllowEnroll(); + // internal testing override necessary to be able to test all study types + const internalTestingOverrides = await browser.studyDebug.getInternalTestingOverrides(); + studySetup.studyType = internalTestingOverrides.studyType; + + studySetup.allowEnroll = await cachingFirstRunShouldAllowEnroll(studySetup); const testingOverrides = await browser.study.getTestingOverrides(); studySetup.testing = { @@ -137,5 +150,6 @@ async function getStudySetup() { firstRunTimestamp: testingOverrides.firstRunTimestamp, expired: testingOverrides.expired, }; + return studySetup; } diff --git a/test/functional/browser.study.api.js b/test/functional/browser.study.api.js index ccb1d78..b8246ec 100644 --- a/test/functional/browser.study.api.js +++ b/test/functional/browser.study.api.js @@ -4,7 +4,7 @@ const KEEPOPEN = process.env.KEEPOPEN; /** Complete list of tests for testing * - * - the public api for `browser.study` + * - the public api for `browser.study` not specific to any add-on background logic */ /** About webdriver extension based tests @@ -43,6 +43,7 @@ const MINUTES_PER_DAY = 60 * 24; // node's util, for printing a deeply nested object to node console const { inspect } = require("util"); + // eslint-disable-next-line no-unused-vars function full(myObject) { return inspect(myObject, { showHidden: false, depth: null }); @@ -56,55 +57,58 @@ function merge(...sources) { return Object.assign({}, ...sources); } -/** return a studySetup, shallow merged from overrides - * - * @return {object} mergedStudySetup - */ -function studySetupForTests(...overrides) { - // Minimal configuration to pass schema validation - const studySetup = { - activeExperimentName: "shield-utils-test-addon@shield.mozilla.org", - studyType: "shield", - endings: { - ineligible: { - baseUrls: [ - "https://qsurvey.mozilla.com/s3/Shield-Study-Example-Survey/?reason=ineligible", - ], +function publicApiTests(studyType) { + /** return a studySetup, shallow merged from overrides + * + * @return {object} mergedStudySetup + */ + function studySetupForTests(...overrides) { + // Minimal configuration to pass schema validation + const studySetup = { + activeExperimentName: `shield-utils-test-addon@${studyType}.mozilla.org`, + studyType, + endings: { + ineligible: { + baseUrls: [ + "https://qsurvey.mozilla.com/s3/Shield-Study-Example-Survey/?reason=ineligible", + ], + }, + BrowserStudyApiEnding: { + baseUrls: [ + "https://qsurvey.mozilla.com/s3/Shield-Study-Example-Survey/?reason=BrowserStudyApiEnding", + ], + }, }, - BrowserStudyApiEnding: { - baseUrls: [ - "https://qsurvey.mozilla.com/s3/Shield-Study-Example-Survey/?reason=BrowserStudyApiEnding", - ], + telemetry: { + send: false, + removeTestingFlag: false, + internalTelemetryArchive: true, }, - }, - telemetry: { - send: false, // assumed false. Actually send pings if true - removeTestingFlag: false, // Marks pings to be discarded, set true for to have the pings processed in the pipeline - }, - logLevel: 10, - weightedVariations: [ - { - name: "control", - weight: 1, + logLevel: 10, + weightedVariations: [ + { + name: "control", + weight: 1, + }, + ], + expire: { + days: 14, }, - ], - expire: { - days: 14, - }, - // Dynamic study configuration flags - allowEnroll: true, - testing: {}, - }; - - return merge(studySetup, ...overrides); -} + // Dynamic study configuration flags + allowEnroll: true, + testing: {}, + }; + + return merge(studySetup, ...overrides); + } -describe("PUBLIC API `browser.study` (not specific to any add-on background logic)", function() { // This gives Firefox time to start, and us a bit longer during some of the tests. - this.timeout(15000 + KEEPOPEN * 1000 * 2); + this.timeout(30000 + KEEPOPEN * 1000 * 2); let driver; + let beginTime; let addonId; + // run in the extension page let addonExec; @@ -112,7 +116,7 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi driver = await utils.setupWebdriver.promiseSetupDriver( utils.FIREFOX_PREFERENCES, ); - addonId = await utils.setupWebdriver.installAddon(driver); + await installAddon(); await utils.ui.openBrowserConsole(driver); // make a shorter alias @@ -122,9 +126,16 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi ); } - async function reinstallAddon() { - await utils.setupWebdriver.uninstallAddon(driver, addonId); - await utils.setupWebdriver.installAddon(driver); + async function installAddon() { + beginTime = Date.now(); + if (addonId) { + await utils.setupWebdriver.uninstallAddon(driver, addonId); + addonId = null; + } + if (studyType === "pioneer") { + await utils.setupWebdriver.installPioneerOptInAddon(driver); + } + addonId = await utils.setupWebdriver.installAddon(driver); } before(createAddonExec); @@ -240,6 +251,33 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }); }); + describe("getDataPermissions", function() { + it("returns correct and current list of permissions", async () => { + const thisSetup = studySetupForTests(); + const dataPermissions = await addonExec(async (setup, cb) => { + // this is what runs in the webExtension scope. + const $dataPermissions = await browser.study.getDataPermissions(); + // call back with all the data we care about to Mocha / node + cb($dataPermissions); + }, thisSetup); + // console.debug(full(dataPermissions)); + + // tests + assert(dataPermissions.shield, "shield should be enabled"); + if (studyType === "pioneer") { + assert( + dataPermissions.pioneer, + "user should have opted in for pioneer", + ); + } else { + assert( + !dataPermissions.pioneer, + "user should not have opted in for pioneer", + ); + } + }); + }); + describe("test the setup requirement", function() { it("should not be able to send telemetry before setup", async () => { const caughtError = await utils.executeJs.executeAsyncScriptInExtensionPageForTests( @@ -315,9 +353,9 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi // tests const now = Number(Date.now()); - const seenTelemetryStates = internals.seenTelemetry["shield-study"].map( - x => x.data.study_state, - ); + const seenTelemetryStates = internals.seenTelemetry + .filter(ping => ping.payload.type === "shield-study") + .map(ping => ping.payload.data.study_state); assert(internals.isSetup, "should be isSetup"); assert(!internals.isEnded, "should not be ended"); assert(!internals.isEnding, "should not be ending"); @@ -366,9 +404,9 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi const { info, internals } = data; // tests - const seenTelemetryStates = internals.seenTelemetry["shield-study"].map( - x => x.data.study_state, - ); + const seenTelemetryStates = internals.seenTelemetry + .filter(ping => ping.payload.type === "shield-study") + .map(ping => ping.payload.data.study_state); assert(internals.isSetup, "should be isSetup"); assert(!internals.isEnded, "should not be ended"); @@ -419,9 +457,9 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi const { info, internals } = data; // tests - const seenTelemetryStates = internals.seenTelemetry["shield-study"].map( - x => x.data.study_state, - ); + const seenTelemetryStates = internals.seenTelemetry + .filter(ping => ping.payload.type === "shield-study") + .map(ping => ping.payload.data.study_state); assert(internals.isSetup, "should be isSetup"); assert(internals.isEnded, "should be ended"); @@ -572,12 +610,13 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi describe("life-cycle tests", function() { describe("setup, sendTelemetry, manually invoked endStudy", function() { - let studyInfo; + let studyInfo, calculatedPingSize; const overrides = { activeExperimentName: "test:browser.study.api", telemetry: { send: true, removeTestingFlag: false, + internalTelemetryArchive: true, }, endings: { customEnding: { @@ -590,15 +629,24 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }; before(async function reinstallSetupDoTelemetryAndWait() { - await reinstallAddon(); - studyInfo = await addonExec(async (_studySetupForTests, callback) => { + await installAddon(); + const _ = await addonExec(async (_studySetupForTests, callback) => { // Ensure we have a configured study and are supposed to run our feature browser.study.onReady.addListener(async _studyInfo => { - await browser.study.sendTelemetry({ foo: "bar" }); - callback(_studyInfo); + const samplePing = { foo: "bar" }; + await browser.study.sendTelemetry(samplePing); + const _calculatedPingSize = await browser.study.calculateTelemetryPingSize( + samplePing, + ); + callback({ + studyInfo: _studyInfo, + calculatedPingSize: _calculatedPingSize, + }); }); await browser.study.setup(_studySetupForTests); }, studySetupForTests(overrides)); + studyInfo = _.studyInfo; + calculatedPingSize = _.calculatedPingSize; await delay(1000); // wait a second to telemetry to settle on disk. }); @@ -610,18 +658,35 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi ); }); + it("calculated ping size is as expected", async () => { + const expectedPingSizes = { + shield: 20, + pioneer: 662, + }; + assert.strictEqual(calculatedPingSize, expectedPingSizes[studyType]); + }); + describe("telemetry archive / controller effects", function() { let studyPings; before(async () => { studyPings = await addonExec(async callback => { const _studyPings = await browser.study.searchSentTelemetry({ - type: ["shield-study", "shield-study-addon"], + type: [ + "shield-study", + "shield-study-addon", + "shield-study-error", + "pioneer-study", + ], }); - callback(_studyPings); + const internals = await browser.studyDebug.getInternals(); + callback({ + sent: _studyPings, + seen: internals.seenTelemetry.reverse(), + }); // Using reverse() to mimic the default sorting of telemetry archive results }); - // console.debug(full(studyPings.map(x => x.payload))); // For debugging tests - // console.debug("Pings report: ", utils.telemetry.pingsReport(studyPings)); + // console.debug("Pings report: ", utils.telemetry.pingsReport(studyPings.seen)); + // console.debug("Pings with id and payload: ", utils.telemetry.pingsDebug(studyPings.seen)); }); it("should have set the experiment to active in Telemetry", async () => { @@ -634,67 +699,62 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi ); }); - it("shield-study-addon telemetry should be working (as seen by telemetry)", async () => { - const shieldTelemetryPings = await addonExec(async callback => { - const _studyPings = await browser.study.searchSentTelemetry({ - type: ["shield-study-addon"], - }); - callback(_studyPings.map(x => x.payload)); - }); - // console.debug("pings", full(shieldTelemetryPings)); - assert(shieldTelemetryPings[0].data.attributes.foo === "bar"); - }); - it("should have sent at least one shield telemetry ping", async () => { - assert(studyPings.length > 0, "at least one shield telemetry ping"); - }); - - it("should have sent one shield-study telemetry ping with study_state=enter", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "enter", - ); assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=enter", + studyPings.sent.length > 0, + "at least one shield telemetry ping", ); }); - it("should have sent one shield-study telemetry ping with study_state=installed", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "installed", + it("should have sent expected telemetry", async () => { + const observed = utils.telemetry.summarizePings( + studyType === "shield" ? studyPings.sent : studyPings.seen, ); - assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=installed", - ); - }); - - it("should have sent one shield-study-addon telemetry ping with payload.data.attributes.foo=bar", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study-addon" && - ping.payload.data.attributes.foo === "bar", - ); - assert( - filteredPings.length > 0, - "at least one shield-study-addon telemetry ping with payload.data.attributes.foo=bar", + const expected = [ + [ + "shield-study-addon", + { + attributes: { + foo: "bar", + }, + }, + ], + [ + "shield-study", + { + study_state: "installed", + }, + ], + [ + "shield-study", + { + study_state: "enter", + }, + ], + ]; + assert.deepStrictEqual( + expected, + observed, + "telemetry pings as as expected", ); }); }); describe("browser.study.endStudy() side effects for first time called", function() { - let endingResult; + let endingResult, endingInternals; before(async () => { - endingResult = await addonExec(async callback => { + const _ = await addonExec(async callback => { browser.study.onEndStudy.addListener(async _endingResult => { - callback(_endingResult); + const internals = await browser.studyDebug.getInternals(); + callback({ + endingResult: _endingResult, + endingInternals: internals, + }); }); await browser.study.endStudy("customEnding"); }); + endingResult = _.endingResult; + endingInternals = _.endingInternals; // let telemetry and disk/files sync up await delay(1000); }); @@ -742,36 +802,75 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi let studyPings; before(async () => { - studyPings = await utils.telemetry.searchSentTelemetry(driver, { - type: ["shield-study", "shield-study-addon"], - }); + studyPings = {}; + studyPings.seen = endingInternals.seenTelemetry.reverse(); + studyPings.sent = await utils.telemetry.searchSentTelemetry( + driver, + { + type: [ + "shield-study", + "shield-study-addon", + "shield-study-error", + "pioneer-study", + ], + timestamp: beginTime, + }, + ); // For debugging tests - // console.debug(full(studyPings.map(x => [x.type, x.payload]))); - // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings)); + // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings.seen)); + // console.debug("Final pings with id and payload: ", utils.telemetry.pingsDebug(studyPings.seen)); }); - it("one shield-study telemetry ping with study_state=exit", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "exit", - ); + it("should have sent at least one shield telemetry ping", async () => { assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=exit", + studyPings.sent.length > 0, + "at least one shield telemetry ping", ); }); - it("one shield-study telemetry ping with study_state_fullname=customEnding", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "ended-positive" && - ping.payload.data.study_state_fullname === "customEnding", + it("should have sent expected telemetry", async () => { + const observed = utils.telemetry.summarizePings( + studyType === "shield" ? studyPings.sent : studyPings.seen, ); - assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state_fullname=customEnding", + const expected = [ + [ + "shield-study", + { + study_state: "exit", + }, + ], + [ + "shield-study", + { + study_state: "ended-positive", + study_state_fullname: "customEnding", + }, + ], + [ + "shield-study-addon", + { + attributes: { + foo: "bar", + }, + }, + ], + [ + "shield-study", + { + study_state: "installed", + }, + ], + [ + "shield-study", + { + study_state: "enter", + }, + ], + ]; + assert.deepStrictEqual( + expected, + observed, + "telemetry pings as as expected", ); }); }); @@ -779,12 +878,13 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }); describe("setup of an ineligible study should result in endStudy('ineligible') without even emitting onReady", function() { - let endingResult; + let endingResult, endingInternals; const overrides = { activeExperimentName: "test:browser.study.api", telemetry: { send: true, removeTestingFlag: false, + internalTelemetryArchive: true, }, endings: { ineligible: { @@ -796,31 +896,31 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }; before(async function reinstallSetupAndAwaitEndStudy() { - await reinstallAddon(); - endingResult = await addonExec( - async (_studySetupForTests, callback) => { - // Ensure we have a configured study and are supposed to run our feature - browser.study.onEndStudy.addListener(async _endingResult => { - console.log( - "In resetSetupAndAwaitEndStudy - onEndStudy listener", - _endingResult, - ); - callback(_endingResult); - }); - browser.study.onReady.addListener(async _studyInfo => { - console.log( - "In resetSetupAndAwaitEndStudy - onReady listener", - _studyInfo, - ); - throw new Error( - "onReady should not have been emitted", - _studyInfo, - ); + await installAddon(); + const _ = await addonExec(async (_studySetupForTests, callback) => { + // Ensure we have a configured study and are supposed to run our feature + browser.study.onEndStudy.addListener(async _endingResult => { + console.log( + "In resetSetupAndAwaitEndStudy - onEndStudy listener", + _endingResult, + ); + const internals = await browser.studyDebug.getInternals(); + callback({ + endingResult: _endingResult, + endingInternals: internals, }); - await browser.study.setup(_studySetupForTests); - }, - studySetupForTests(overrides), - ); + }); + browser.study.onReady.addListener(async _studyInfo => { + console.log( + "In resetSetupAndAwaitEndStudy - onReady listener", + _studyInfo, + ); + throw new Error("onReady should not have been emitted", _studyInfo); + }); + await browser.study.setup(_studySetupForTests); + }, studySetupForTests(overrides)); + endingResult = _.endingResult; + endingInternals = _.endingInternals; }); describe("browser.study.endStudy() side effects", function() { @@ -850,35 +950,61 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi let studyPings; before(async () => { - studyPings = await utils.telemetry.searchSentTelemetry(driver, { - type: ["shield-study", "shield-study-addon"], - }); + studyPings = {}; + studyPings.seen = endingInternals.seenTelemetry.reverse(); + studyPings.sent = await utils.telemetry.searchSentTelemetry( + driver, + { + type: [ + "shield-study", + "shield-study-addon", + "shield-study-error", + "pioneer-study", + ], + timestamp: beginTime, + }, + ); // For debugging tests - // console.debug(full(studyPings.map(x => [x.type, x.payload]))); - // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings)); + // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings.seen)); + // console.debug("Final pings with id and payload: ", utils.telemetry.pingsDebug(studyPings.seen)); }); - it("one shield-study telemetry ping with study_state=exit", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "exit", - ); + it("should have sent at least one shield telemetry ping", async () => { assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=exit", + studyPings.sent.length > 0, + "at least one shield telemetry ping", ); }); - it("one shield-study telemetry ping with study_state=ineligible", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "ineligible", + it("should have sent expected telemetry", async () => { + const observed = utils.telemetry.summarizePings( + studyType === "shield" ? studyPings.sent : studyPings.seen, ); - assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=ineligible", + const expected = [ + [ + "shield-study", + { + study_state: "exit", + }, + ], + [ + "shield-study", + { + study_state: "ineligible", + study_state_fullname: "ineligible", + }, + ], + [ + "shield-study", + { + study_state: "enter", + }, + ], + ]; + assert.deepStrictEqual( + expected, + observed, + "telemetry pings as as expected", ); }); }); @@ -886,12 +1012,13 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }); describe("setup of an already expired study should result in endStudy('expired') without even emitting onReady", function() { - let endingResult; + let endingResult, endingInternals; const overrides = { activeExperimentName: "test:browser.study.api", telemetry: { send: true, removeTestingFlag: false, + internalTelemetryArchive: true, }, endings: { expired: { @@ -906,31 +1033,31 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }; before(async function reinstallSetupAndAwaitEndStudy() { - await reinstallAddon(); - endingResult = await addonExec( - async (_studySetupForTests, callback) => { - // Ensure we have a configured study and are supposed to run our feature - browser.study.onEndStudy.addListener(async _endingResult => { - console.log( - "In resetSetupAndAwaitEndStudy - onEndStudy listener", - _endingResult, - ); - callback(_endingResult); - }); - browser.study.onReady.addListener(async _studyInfo => { - console.log( - "In resetSetupAndAwaitEndStudy - onReady listener", - _studyInfo, - ); - throw new Error( - "onReady should not have been emitted", - _studyInfo, - ); + await installAddon(); + const _ = await addonExec(async (_studySetupForTests, callback) => { + // Ensure we have a configured study and are supposed to run our feature + browser.study.onEndStudy.addListener(async _endingResult => { + console.log( + "In resetSetupAndAwaitEndStudy - onEndStudy listener", + _endingResult, + ); + const internals = await browser.studyDebug.getInternals(); + callback({ + endingResult: _endingResult, + endingInternals: internals, }); - await browser.study.setup(_studySetupForTests); - }, - studySetupForTests(overrides), - ); + }); + browser.study.onReady.addListener(async _studyInfo => { + console.log( + "In resetSetupAndAwaitEndStudy - onReady listener", + _studyInfo, + ); + throw new Error("onReady should not have been emitted", _studyInfo); + }); + await browser.study.setup(_studySetupForTests); + }, studySetupForTests(overrides)); + endingResult = _.endingResult; + endingInternals = _.endingInternals; }); describe("browser.study.endStudy() side effects", function() { @@ -960,35 +1087,61 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi let studyPings; before(async () => { - studyPings = await utils.telemetry.searchSentTelemetry(driver, { - type: ["shield-study", "shield-study-addon"], - }); + studyPings = {}; + studyPings.seen = endingInternals.seenTelemetry.reverse(); + studyPings.sent = await utils.telemetry.searchSentTelemetry( + driver, + { + type: [ + "shield-study", + "shield-study-addon", + "shield-study-error", + "pioneer-study", + ], + timestamp: beginTime, + }, + ); // For debugging tests - // console.debug(full(studyPings.map(x => [x.type, x.payload]))); - // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings)); + // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings.seen)); + // console.debug("Final pings with id and payload: ", utils.telemetry.pingsDebug(studyPings.seen)); }); - it("one shield-study telemetry ping with study_state=exit", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "exit", - ); + it("should have sent at least one shield telemetry ping", async () => { assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=exit", + studyPings.sent.length > 0, + "at least one shield telemetry ping", ); }); - it("one shield-study telemetry ping with study_state=expired", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "expired", + it("should have sent expected telemetry", async () => { + const observed = utils.telemetry.summarizePings( + studyType === "shield" ? studyPings.sent : studyPings.seen, ); - assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=expired", + const expected = [ + [ + "shield-study", + { + study_state: "exit", + }, + ], + [ + "shield-study", + { + study_state: "expired", + study_state_fullname: "expired", + }, + ], + [ + "shield-study", + { + study_state: "enter", + }, + ], + ]; + assert.deepStrictEqual( + expected, + observed, + "telemetry pings as as expected", ); }); }); @@ -996,14 +1149,13 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }); describe("setup of a study that expires within a few seconds should result in endStudy('expired') after a few seconds", function() { - let endingResult; - const now = Number(Date.now()); - const msInOneDay = 60 * 60 * 24 * 1000; + let endingResult, endingInternals; const overrides = { activeExperimentName: "test:browser.study.api", telemetry: { send: true, removeTestingFlag: false, + internalTelemetryArchive: true, }, expire: { days: 1, @@ -1016,56 +1168,65 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }, }, testing: { - firstRunTimestamp: now - msInOneDay + 2000, + firstRunTimestamp: null, // needs to be set in the before-hook below in order to be executed just before the setup of the study }, }; before(async function reinstallSetupAndConfigureAlarm() { - await reinstallAddon(); - endingResult = await addonExec( - async (_studySetupForTests, callback) => { + await installAddon(); + // Set the study to expire after a few seconds + const now = Number(Date.now()); + const msInOneDay = 60 * 60 * 24 * 1000; + overrides.testing.firstRunTimestamp = now - msInOneDay + 5000; + // console.log("Expiration debug: now, firstRunTimestamp, new Date(), new Date(now), new Date(firstRunTimestamp)", now, overrides.testing.firstRunTimestamp, new Date(), new Date(now), new Date(overrides.testing.firstRunTimestamp)); + const _ = await addonExec(async (_studySetupForTests, callback) => { + console.log( + "In resetSetupAndConfigureAlarm - addonExec", + _studySetupForTests, + ); + // Ensure we have a configured study and are supposed to run our feature + browser.study.onEndStudy.addListener(async _endingResult => { console.log( - "In resetSetupAndConfigureAlarm - addonExec", - _studySetupForTests, + "In resetSetupAndConfigureAlarm - onEndStudy listener", + _endingResult, ); - // Ensure we have a configured study and are supposed to run our feature - browser.study.onEndStudy.addListener(async _endingResult => { - console.log( - "In resetSetupAndConfigureAlarm - onEndStudy listener", - _endingResult, - ); - callback(_endingResult); + const internals = await browser.studyDebug.getInternals(); + callback({ + endingResult: _endingResult, + endingInternals: internals, }); - browser.study.onReady.addListener(async _studyInfo => { - console.log( - "In resetSetupAndConfigureAlarm - onReady listener", - _studyInfo, - ); - const { delayInMinutes } = _studyInfo; - if (delayInMinutes !== undefined) { - const alarmName = `${browser.runtime.id}:studyExpiration`; - const alarmListener = async alarm => { - console.log( - "In resetSetupAndConfigureAlarm - alarmListener", - alarm, - ); - if (alarm.name === alarmName) { - browser.alarms.onAlarm.removeListener(alarmListener); - await browser.study.endStudy("expired"); - } - }; - console.log("Setting up alarm listener", alarmListener); - browser.alarms.onAlarm.addListener(alarmListener); - console.log("Creating alarm", alarmName, delayInMinutes); - browser.alarms.create(alarmName, { - delayInMinutes, - }); - } - }); - await browser.study.setup(_studySetupForTests); - }, - studySetupForTests(overrides), - ); + }); + browser.study.onReady.addListener(async _studyInfo => { + console.log( + "In resetSetupAndConfigureAlarm - onReady listener", + _studyInfo, + ); + await browser.study.sendTelemetry({ foo: "bar" }); + const { delayInMinutes } = _studyInfo; + if (delayInMinutes !== undefined) { + const alarmName = `${browser.runtime.id}:studyExpiration`; + const alarmListener = async alarm => { + console.log( + "In resetSetupAndConfigureAlarm - alarmListener", + alarm, + ); + if (alarm.name === alarmName) { + browser.alarms.onAlarm.removeListener(alarmListener); + await browser.study.endStudy("expired"); + } + }; + console.log("Setting up alarm listener", alarmListener); + browser.alarms.onAlarm.addListener(alarmListener); + console.log("Creating alarm", alarmName, delayInMinutes); + browser.alarms.create(alarmName, { + delayInMinutes, + }); + } + }); + await browser.study.setup(_studySetupForTests); + }, studySetupForTests(overrides)); + endingResult = _.endingResult; + endingInternals = _.endingInternals; }); describe("browser.study.endStudy() side effects", function() { @@ -1095,35 +1256,63 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi let studyPings; before(async () => { - studyPings = await utils.telemetry.searchSentTelemetry(driver, { - type: ["shield-study", "shield-study-addon"], - }); + studyPings = {}; + studyPings.seen = endingInternals.seenTelemetry.reverse(); + studyPings.sent = await utils.telemetry.searchSentTelemetry( + driver, + { + type: [ + "shield-study", + "shield-study-addon", + "shield-study-error", + "pioneer-study", + ], + timestamp: beginTime, + }, + ); // For debugging tests - // console.debug(full(studyPings.map(x => [x.type, x.payload]))); - // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings)); + // console.debug("Final pings report: ", utils.telemetry.pingsReport(studyPings.seen)); + // console.debug("Final pings with id and payload: ", utils.telemetry.pingsDebug(studyPings.seen)); }); - it("one shield-study telemetry ping with study_state=exit", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "exit", - ); + it("should have sent at least one shield telemetry ping", async () => { assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=exit", + studyPings.sent.length > 0, + "at least one shield telemetry ping", ); }); - it("one shield-study telemetry ping with study_state=expired", async () => { - const filteredPings = studyPings.filter( - ping => - ping.type === "shield-study" && - ping.payload.data.study_state === "expired", + it("should have sent expected telemetry", async () => { + const observed = utils.telemetry.summarizePings( + studyType === "shield" ? studyPings.sent : studyPings.seen, ); - assert( - filteredPings.length > 0, - "at least one shield-study telemetry ping with study_state=expired", + const expected = [ + [ + "shield-study", + { + study_state: "exit", + }, + ], + [ + "shield-study", + { + study_state: "expired", + study_state_fullname: "expired", + }, + ], + [ + "shield-study-addon", + { + attributes: { + foo: "bar", + }, + }, + ], + ]; + assert.deepStrictEqual( + expected, + observed, + "telemetry pings as as expected", ); }); }); @@ -1168,35 +1357,13 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi }); }); - describe("endStudy", function() { - it("see the browser.study.endStudy() side effects above", () => - assert(true)); - }); - - describe("getStudyInfo", function() { - describe("correctness: see browser.study.setup() tests", function() { - // tests - it("during first run, isFirstRun is true", function() {}); - it("during second run, isFirstRun is false", function() {}); - it("if duration.days in studySetup(), have a delayInMinutes in studyInfo", async function() {}); - }); - }); - - describe("searchSentTelemetry (light testing)", function() { - it("searches get results, see the endStudy() and other test", function() {}); - }); - - describe("uninstall by users?", function() {}); - - // TODO 5.1 - describe.skip("possible 5.1 future tests.", function() { - describe("getDataPermissions", function() { - it("returns correct and current list of permissions"); - }); + // TODO 5.2+ + describe.skip("possible 5.2+ future tests.", function() { + describe("uninstall by users?", function() {}); describe("surveyUrl", function() { describe("needs setup", function() { - it("throws StudyNotsSetupError if not setup"); + it("throws StudyNotSetupError if not setup"); }); describe("correctly constructs urls queryArgs from profile info", function() { it("an example url is correct"); @@ -1206,4 +1373,12 @@ describe("PUBLIC API `browser.study` (not specific to any add-on background logi it("log level works?"); }); }); +} + +describe("PUBLIC API `browser.study` (studyType: shield)", function() { + publicApiTests.call(this, "shield"); +}); + +describe("PUBLIC API `browser.study` (studyType: pioneer)", function() { + publicApiTests.call(this, "pioneer"); }); diff --git a/test/functional/test-addon.js b/test/functional/test-addon.js index 6256024..020cd1c 100644 --- a/test/functional/test-addon.js +++ b/test/functional/test-addon.js @@ -12,7 +12,7 @@ const KEEPOPEN = process.env.KEEPOPEN; const assert = require("assert"); const utils = require("./utils"); -describe("Tests verifying that the test add-on works as expected", function() { +const testAddonTests = function(studyType) { // This gives Firefox time to start, and us a bit longer during some of the tests. this.timeout(15000 + KEEPOPEN * 1000 * 3); @@ -22,6 +22,17 @@ describe("Tests verifying that the test add-on works as expected", function() { driver = await utils.setupWebdriver.promiseSetupDriver( utils.FIREFOX_PREFERENCES, ); + const widgetId = utils.ui.makeWidgetId( + "shield-utils-test-addon@shield.mozilla.org", + ); + await utils.preferences.set( + driver, + `extensions.${widgetId}.test.studyType`, + studyType, + ); + if (studyType === "pioneer") { + await utils.setupWebdriver.installPioneerOptInAddon(driver); + } await utils.setupWebdriver.installAddon(driver); await utils.ui.openBrowserConsole(driver); }); @@ -147,4 +158,12 @@ describe("Tests verifying that the test add-on works as expected", function() { */ }); }); +}; + +describe("Tests verifying that the test add-on works as expected (studyType: shield)", function() { + testAddonTests.bind(this)("shield"); +}); + +describe("Tests verifying that the test add-on works as expected (studyType: pioneer)", function() { + testAddonTests.bind(this)("pioneer"); }); diff --git a/testUtils/setupWebdriver.js b/testUtils/setupWebdriver.js index a7ad728..b22e6a0 100644 --- a/testUtils/setupWebdriver.js +++ b/testUtils/setupWebdriver.js @@ -107,6 +107,20 @@ module.exports.setupWebdriver = { return addonId; }, + /** Install pioneer opt-in add-on from where it is expected to be if its + * repo is cloned in the current working directory and the xpi then built within + * + * @param {object} driver Configured Firefox webdriver + * @param {string} fileLocation location for add-on xpi/zip + * @returns {Promise} returns add-on id) + */ + installPioneerOptInAddon: async (driver, fileLocation) => { + fileLocation = + fileLocation || + path.join(process.cwd(), "pioneer-opt-in/pioneer-opt-in.xpi"); + return module.exports.setupWebdriver.installAddon(driver, fileLocation); + }, + uninstallAddon: async (driver, addonId) => { const executor = driver.getExecutor(); executor.defineCommand( diff --git a/testUtils/telemetry.js b/testUtils/telemetry.js index 974b6a7..7bb0eaa 100644 --- a/testUtils/telemetry.js +++ b/testUtils/telemetry.js @@ -7,6 +7,14 @@ const { const firefox = require("selenium-webdriver/firefox"); const Context = firefox.Context; +// node's util, for printing a deeply nested object to node console +const { inspect } = require("util"); + +// eslint-disable-next-line no-unused-vars +function full(myObject) { + return inspect(myObject, { showHidden: false, depth: null }); +} + module.exports.telemetry = { getActiveExperiments: async driver => { driver.setContext(Context.CHROME); @@ -52,6 +60,14 @@ module.exports.telemetry = { return pings.map(p => [p.payload.type, p.payload.data]); }, + pingsDebug: pings => { + return full( + pings.map(x => { + return { id: x.id, payload: x.payload }; + }), + ); + }, + pingsReport: pings => { if (pings.length === 0) { return { report: "No pings found" }; diff --git a/testUtils/ui.js b/testUtils/ui.js index e1cda83..75827df 100644 --- a/testUtils/ui.js +++ b/testUtils/ui.js @@ -26,6 +26,18 @@ module.exports.ui = { return JSON.parse(manifestJson); }, + /** + * From firefox/browser/components/extensions/ExtensionPopups.jsm + * + * @param {string} id Id to modify + * @returns {string} widgetId canonical widget id with replaced bits. + */ + makeWidgetId: id => { + id = id.toLowerCase(); + // FIXME: This allows for collisions. + return id.replace(/[^a-z0-9_-]/g, "_"); + }, + /** * The widget id is used to identify add-on specific chrome elements. Examples: * - Browser action - {addonWidgetId}-browser-action @@ -34,20 +46,8 @@ module.exports.ui = { * @returns {Promise} name of the made widget */ addonWidgetId: async () => { - /** - * From firefox/browser/components/extensions/ExtensionPopups.jsm - * - * @param {string} id Id to modify - * @returns {string} widgetId canonical widget id with replaced bits. - */ - function makeWidgetId(id) { - id = id.toLowerCase(); - // FIXME: This allows for collisions. - return id.replace(/[^a-z0-9_-]/g, "_"); - } - const manifest = await module.exports.ui.promiseManifest(); - return makeWidgetId(manifest.applications.gecko.id); + return module.exports.ui.makeWidgetId(manifest.applications.gecko.id); }, openBrowserConsole: async driver => { diff --git a/webExtensionApis/study/api.md b/webExtensionApis/study/api.md index 7f59d2d..f00a366 100644 --- a/webExtensionApis/study/api.md +++ b/webExtensionApis/study/api.md @@ -11,7 +11,7 @@ Attempt an setup/enrollment, with these effects: * sets 'studyType' as Shield or Pioneer * affects telemetry - * (5.1 TODO) watches for dataPermission changes that should _always_ + * (5.2+ TODO) watches for dataPermission changes that should _always_ stop that kind of study * Use or choose variation @@ -139,18 +139,20 @@ Throws Error if called before `browser.study.setup` **Parameters** +### `browser.study.getDataPermissions( )` + +Object of current dataPermissions (shield enabled true/false, pioneer enabled true/false) + +**Parameters** + ### `browser.study.sendTelemetry( payload )` Send Telemetry using appropriate shield or pioneer methods. -shield: +shield & pioneer sends using the following schema: * `shield-study-addon` ping, requires object string keys and string values -pioneer: - -* TBD - Note: * no conversions / coercion of data happens. @@ -158,12 +160,30 @@ Note: Note: * undefined what happens if validation fails -* undefined what happens when you try to send 'shield' from 'pioneer' TBD fix the parameters here. **Parameters** +* `payload` + * type: payload + * $ref: + * optional: false + +### `browser.study.calculateTelemetryPingSize( payload )` + +Calculate Telemetry using appropriate shield or pioneer methods. + +shield: + +* Calculate the size of a ping + +pioneer: + +* Calculate the size of a ping that has Pioneer encrypted data + +**Parameters** + * `payload` * type: payload * $ref: @@ -284,7 +304,34 @@ Act on it by } ``` -### [1] NullableInteger +### [1] NullableBoolean + +```json +{ + "id": "NullableBoolean", + "$schema": "http://json-schema.org/draft-04/schema", + "oneOf": [ + { + "type": "null" + }, + { + "type": "boolean" + } + ], + "choices": [ + { + "type": "null" + }, + { + "type": "boolean" + } + ], + "testcases": [null, true, false], + "failcases": ["1234567890", "foo", []] +} +``` + +### [2] NullableInteger ```json { @@ -311,7 +358,7 @@ Act on it by } ``` -### [2] NullableNumber +### [3] NullableNumber ```json { @@ -338,7 +385,7 @@ Act on it by } ``` -### [3] studyTypesEnum +### [4] studyTypesEnum ```json { @@ -351,7 +398,7 @@ Act on it by } ``` -### [4] weightedVariationObject +### [5] weightedVariationObject ```json { @@ -375,7 +422,7 @@ Act on it by } ``` -### [5] weightedVariationsArray +### [6] weightedVariationsArray ```json { @@ -408,7 +455,7 @@ Act on it by } ``` -### [6] anEndingRequest +### [7] anEndingRequest ```json { @@ -519,7 +566,7 @@ Act on it by } ``` -### [7] onEndStudyResponse +### [8] onEndStudyResponse ```json { @@ -541,7 +588,7 @@ Act on it by } ``` -### [8] studyInfoObject +### [9] studyInfoObject ```json { @@ -575,7 +622,26 @@ Act on it by } ``` -### [9] studySetup +### [10] dataPermissionsObject + +```json +{ + "id": "dataPermissionsObject", + "type": "object", + "additionalProperties": false, + "properties": { + "shield": { + "type": "boolean" + }, + "pioneer": { + "type": "boolean" + } + }, + "required": ["shield", "pioneer"] +} +``` + +### [11] studySetup ```json { @@ -616,6 +682,10 @@ Act on it by }, "removeTestingFlag": { "type": "boolean" + }, + "internalTelemetryArchive": { + "optional": true, + "$ref": "NullableBoolean" } } }, @@ -704,7 +774,8 @@ Act on it by ], "telemetry": { "send": false, - "removeTestingFlag": false + "removeTestingFlag": false, + "internalTelemetryArchive": false }, "testing": { "variationName": "something", @@ -728,7 +799,8 @@ Act on it by ], "telemetry": { "send": false, - "removeTestingFlag": true + "removeTestingFlag": true, + "internalTelemetryArchive": true }, "testing": { "variationName": "something", @@ -787,7 +859,7 @@ Act on it by } ``` -### [10] telemetryPayload +### [12] telemetryPayload ```json { @@ -801,7 +873,7 @@ Act on it by } ``` -### [11] searchTelemetryQuery +### [13] searchTelemetryQuery ```json { @@ -839,7 +911,7 @@ Act on it by } ``` -### [12] anEndingAnswer +### [14] anEndingAnswer ```json { @@ -1006,11 +1078,20 @@ About `this._internals`: * isSetup: bool `setup` * isFirstRun: bool `setup`, based on pref * studySetup: bool `setup` the config -* seenTelemetry: object of lists of seen telemetry by bucket +* seenTelemetry: array of seen telemetry. Fully populated only if studySetup.telemetry.internalTelemetryArchive is true * prefs: object of all created prefs and their names **Parameters** +### `browser.studyDebug.getInternalTestingOverrides( )` + +Returns an object with the following keys: +studyType - to be able to test add-ons with different studyType configurations +Used to override study testing flags in getStudySetup(). +The values are set by the corresponding preference under the `extensions.${widgetId}.test.*` preference branch. + +**Parameters** + ## Events (None) diff --git a/webExtensionApis/study/schema.json b/webExtensionApis/study/schema.json index 88f0737..282e2ab 100644 --- a/webExtensionApis/study/schema.json +++ b/webExtensionApis/study/schema.json @@ -25,6 +25,28 @@ ], "testcases": [null, "a string"] }, + { + "id": "NullableBoolean", + "$schema": "http://json-schema.org/draft-04/schema", + "oneOf": [ + { + "type": "null" + }, + { + "type": "boolean" + } + ], + "choices": [ + { + "type": "null" + }, + { + "type": "boolean" + } + ], + "testcases": [null, true, false], + "failcases": ["1234567890", "foo", []] + }, { "id": "NullableInteger", "$schema": "http://json-schema.org/draft-04/schema", @@ -276,6 +298,20 @@ "isFirstRun" ] }, + { + "id": "dataPermissionsObject", + "type": "object", + "additionalProperties": false, + "properties": { + "shield": { + "type": "boolean" + }, + "pioneer": { + "type": "boolean" + } + }, + "required": ["shield", "pioneer"] + }, { "id": "studySetup", "$schema": "http://json-schema.org/draft-04/schema", @@ -314,6 +350,10 @@ }, "removeTestingFlag": { "type": "boolean" + }, + "internalTelemetryArchive": { + "optional": true, + "$ref": "NullableBoolean" } } }, @@ -402,7 +442,8 @@ ], "telemetry": { "send": false, - "removeTestingFlag": false + "removeTestingFlag": false, + "internalTelemetryArchive": false }, "testing": { "variationName": "something", @@ -426,7 +467,8 @@ ], "telemetry": { "send": false, - "removeTestingFlag": true + "removeTestingFlag": true, + "internalTelemetryArchive": true }, "testing": { "variationName": "something", @@ -539,7 +581,7 @@ "type": "function", "async": true, "description": - "Attempt an setup/enrollment, with these effects:\n\n- sets 'studyType' as Shield or Pioneer\n - affects telemetry\n - (5.1 TODO) watches for dataPermission changes that should *always*\n stop that kind of study\n\n- Use or choose variation\n - `testing.variation` if present\n - OR (internal) deterministicVariation\n from `weightedVariations`\n based on hash of\n\n - activeExperimentName\n - clientId\n\n- During firstRun[1] only:\n - set firstRunTimestamp pref value\n - send 'enter' ping\n - if `allowEnroll`, send 'install' ping\n - else endStudy(\"ineligible\") and return\n\n- Every Run\n - setActiveExperiment(studySetup)\n - monitor shield | pioneer permission endings\n - suggests alarming if `expire` is set.\n\nReturns:\n- studyInfo object (see `getStudyInfo`)\n\nTelemetry Sent (First run only)\n\n - enter\n - install\n\nFires Events\n\n(At most one of)\n- study:onReady OR\n- study:onEndStudy\n\nPreferences set\n- `shield.${runtime.id}.firstRunTimestamp`\n\nNote:\n1. allowEnroll is ONLY used during first run (install)\n", + "Attempt an setup/enrollment, with these effects:\n\n- sets 'studyType' as Shield or Pioneer\n - affects telemetry\n - (5.2+ TODO) watches for dataPermission changes that should *always*\n stop that kind of study\n\n- Use or choose variation\n - `testing.variation` if present\n - OR (internal) deterministicVariation\n from `weightedVariations`\n based on hash of\n\n - activeExperimentName\n - clientId\n\n- During firstRun[1] only:\n - set firstRunTimestamp pref value\n - send 'enter' ping\n - if `allowEnroll`, send 'install' ping\n - else endStudy(\"ineligible\") and return\n\n- Every Run\n - setActiveExperiment(studySetup)\n - monitor shield | pioneer permission endings\n - suggests alarming if `expire` is set.\n\nReturns:\n- studyInfo object (see `getStudyInfo`)\n\nTelemetry Sent (First run only)\n\n - enter\n - install\n\nFires Events\n\n(At most one of)\n- study:onReady OR\n- study:onEndStudy\n\nPreferences set\n- `shield.${runtime.id}.firstRunTimestamp`\n\nNote:\n1. allowEnroll is ONLY used during first run (install)\n", "parameters": [ { "name": "studySetup", @@ -593,11 +635,28 @@ } ] }, + { + "name": "getDataPermissions", + "type": "function", + "async": true, + "description": + "Object of current dataPermissions (shield enabled true/false, pioneer enabled true/false)", + "defaultReturn": { + "shield": true, + "pioneer": false + }, + "parameters": [], + "returns": [ + { + "$ref": "dataPermissionsObject" + } + ] + }, { "name": "sendTelemetry", "type": "function", "description": - "Send Telemetry using appropriate shield or pioneer methods.\n\nshield:\n- `shield-study-addon` ping, requires object string keys and string values\n\npioneer:\n- TBD\n\nNote:\n- no conversions / coercion of data happens.\n\nNote:\n- undefined what happens if validation fails\n- undefined what happens when you try to send 'shield' from 'pioneer'\n\nTBD fix the parameters here.\n", + "Send Telemetry using appropriate shield or pioneer methods.\n\nshield & pioneer sends using the following schema:\n- `shield-study-addon` ping, requires object string keys and string values\n\nNote:\n- no conversions / coercion of data happens.\n\nNote:\n- undefined what happens if validation fails\n\nTBD fix the parameters here.\n", "async": true, "parameters": [ { @@ -608,6 +667,25 @@ "defaultReturn": "undefined", "returns": null }, + { + "name": "calculateTelemetryPingSize", + "type": "function", + "description": + "Calculate Telemetry using appropriate shield or pioneer methods.\n\nshield:\n- Calculate the size of a ping\n\npioneer:\n- Calculate the size of a ping that has Pioneer encrypted data\n", + "async": true, + "parameters": [ + { + "name": "payload", + "$ref": "telemetryPayload" + } + ], + "defaultReturn": "undefined", + "returns": [ + { + "type": "number" + } + ] + }, { "name": "searchSentTelemetry", "type": "function", @@ -864,7 +942,15 @@ "type": "function", "async": true, "description": - "Return `_internals` of the studyUtils object.\n\nUse this for debugging state.\n\nAbout `this._internals`:\n- variation: (chosen variation, `setup` )\n- isEnding: bool `endStudy`\n- isSetup: bool `setup`\n- isFirstRun: bool `setup`, based on pref\n- studySetup: bool `setup` the config\n- seenTelemetry: object of lists of seen telemetry by bucket\n- prefs: object of all created prefs and their names\n", + "Return `_internals` of the studyUtils object.\n\nUse this for debugging state.\n\nAbout `this._internals`:\n- variation: (chosen variation, `setup` )\n- isEnding: bool `endStudy`\n- isSetup: bool `setup`\n- isFirstRun: bool `setup`, based on pref\n- studySetup: bool `setup` the config\n- seenTelemetry: array of seen telemetry. Fully populated only if studySetup.telemetry.internalTelemetryArchive is true\n- prefs: object of all created prefs and their names\n", + "parameters": [] + }, + { + "name": "getInternalTestingOverrides", + "type": "function", + "async": true, + "description": + "Returns an object with the following keys:\n studyType - to be able to test add-ons with different studyType configurations\nUsed to override study testing flags in getStudySetup().\nThe values are set by the corresponding preference under the `extensions.${widgetId}.test.*` preference branch.\n", "parameters": [] } ] diff --git a/webExtensionApis/study/schema.yaml b/webExtensionApis/study/schema.yaml index 944e9a8..66b59e0 100644 --- a/webExtensionApis/study/schema.yaml +++ b/webExtensionApis/study/schema.yaml @@ -34,14 +34,25 @@ {type: 'string'}] testcases: [null, 'a string'] + - id: NullableBoolean + $schema: "http://json-schema.org/draft-04/schema" + oneOf: [ + {type: 'null'}, + {type: 'boolean'}] + choices: [ + {type: 'null'}, + {type: 'boolean'}] + testcases: [null, true, false] + failcases: ['1234567890', 'foo', []] + - id: NullableInteger $schema: "http://json-schema.org/draft-04/schema" oneOf: [ - {type: 'null'}, - {type: 'integer'}] + {type: 'null'}, + {type: 'integer'}] choices: [ - {type: 'null'}, - {type: 'integer'}] + {type: 'null'}, + {type: 'integer'}] testcases: [null, 1234567890] failcases: ['1234567890', []] @@ -169,16 +180,20 @@ - activeExperimentName - isFirstRun - #- id: dataPermissionsObject - # type: object - # additionalProperties: true - # properties: - # shield: - # type: - # boolean - # - # required: - # - shield + - id: dataPermissionsObject + type: object + additionalProperties: false + properties: + shield: + type: + boolean + pioneer: + type: + boolean + + required: + - shield + - pioneer - id: studySetup $schema: "http://json-schema.org/draft-04/schema" @@ -208,6 +223,9 @@ type: boolean removeTestingFlag: type: boolean + internalTelemetryArchive: + optional: true + $ref: NullableBoolean testing: type: object properties: @@ -246,7 +264,7 @@ expire: { "days": 10}, endings: {anEnding: {baseUrls: ['some.url']}}, weightedVariations: [{name: feature-active, weight: 1.5}], - telemetry: { send: false, removeTestingFlag: false}, + telemetry: { send: false, removeTestingFlag: false, internalTelemetryArchive: false}, testing: { variationName: "something", firstRunTimestamp: 1234567890, @@ -258,7 +276,7 @@ studyType: 'pioneer', endings: {anEnding: {baseUrls: ['some.url']}}, weightedVariations: [{name: feature-active, weight: 1.5}], - telemetry: { send: false, removeTestingFlag: true}, + telemetry: { send: false, removeTestingFlag: true, internalTelemetryArchive: true}, testing: { variationName: "something", firstRunTimestamp: 1234567890, @@ -299,7 +317,7 @@ - sets 'studyType' as Shield or Pioneer - affects telemetry - - (5.1 TODO) watches for dataPermission changes that should *always* + - (5.2+ TODO) watches for dataPermission changes that should *always* stop that kind of study - Use or choose variation @@ -423,14 +441,14 @@ returns: - $ref: studyInfoObject - # - name: getDataPermissions - # type: function - # async: true - # description: object of current dataPermissions with keys shield, pioneer, telemetry, 'ok' - # defaultReturn: {shield: true, pioneer: false, telemetry: true, alwaysPrivateBrowsing: false} - # parameters: [] - # returns: - # - $ref: dataPermissionsObject + - name: getDataPermissions + type: function + async: true + description: Object of current dataPermissions (shield enabled true/false, pioneer enabled true/false) + defaultReturn: {shield: true, pioneer: false} + parameters: [] + returns: + - $ref: dataPermissionsObject # telemetry related things - name: sendTelemetry @@ -438,18 +456,11 @@ description: | Send Telemetry using appropriate shield or pioneer methods. - shield: - - `shield-study-addon` ping, requires object string keys and string values - - pioneer: - - TBD + Note: The payload must adhere to the `data.attributes` property in the [`shield-study-addon`](https://github.com/mozilla-services/mozilla-pipeline-schemas/blob/dev/templates/include/telemetry/shieldStudyAddonPayload.3.schema.json) schema. That is, it must be a flat object with string keys and string values. Note: - no conversions / coercion of data happens. - - Note: - undefined what happens if validation fails - - undefined what happens when you try to send 'shield' from 'pioneer' TBD fix the parameters here. @@ -461,6 +472,26 @@ defaultReturn: undefined # exception if out of policy based on config returns: + - name: calculateTelemetryPingSize + type: function + description: | + Calculate Telemetry using appropriate shield or pioneer methods. + + shield: + - Calculate the size of a ping + + pioneer: + - Calculate the size of a ping that has Pioneer encrypted data + + async: true + parameters: + - name: payload + $ref: telemetryPayload + + defaultReturn: undefined + returns: + - type: number + - name: searchSentTelemetry type: function async: true @@ -557,7 +588,7 @@ - name: ending type: object - # TODO 5.1 + # TODO 5.2+ # - name: onDataPermissionsChange # type: function # defaultReturn: {shield: true, pioneer: false} @@ -694,7 +725,17 @@ - isSetup: bool `setup` - isFirstRun: bool `setup`, based on pref - studySetup: bool `setup` the config - - seenTelemetry: object of lists of seen telemetry by bucket + - seenTelemetry: array of seen telemetry. Fully populated only if studySetup.telemetry.internalTelemetryArchive is true - prefs: object of all created prefs and their names parameters: [] + + - name: getInternalTestingOverrides + type: 'function' + async: true + description: | + Returns an object with the following keys: + studyType - to be able to test add-ons with different studyType configurations + Used to override study testing flags in getStudySetup(). + The values are set by the corresponding preference under the `extensions.${widgetId}.test.*` preference branch. + parameters: [] diff --git a/webExtensionApis/study/src/dataPermissions.js b/webExtensionApis/study/src/dataPermissions.js new file mode 100644 index 0000000..984b127 --- /dev/null +++ b/webExtensionApis/study/src/dataPermissions.js @@ -0,0 +1,39 @@ +const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm", + {}, +); +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm", + {}, +); + +/** + * Checks to see if SHIELD is enabled for a user. + * + * @returns {Boolean} + * A boolean to indicate SHIELD opt-in status. + */ +export function isShieldEnabled() { + return Services.prefs.getBoolPref("app.shield.optoutstudies.enabled", true); +} + +/** + * Checks to see if the user has opted in to Pioneer. This is + * done by checking that the opt-in addon is installed and active. + * + * @returns {Boolean} + * A boolean to indicate opt-in status. + */ +export async function isUserOptedInToPioneer() { + const addon = await AddonManager.getAddonByID("pioneer-opt-in@mozilla.org"); + return isShieldEnabled() && addon !== null && addon.isActive; +} + +export async function getDataPermissions() { + const shield = isShieldEnabled(); + const pioneer = await isUserOptedInToPioneer(); + return { + shield, + pioneer, + }; +} diff --git a/webExtensionApis/study/src/getPingSize.js b/webExtensionApis/study/src/getPingSize.js new file mode 100644 index 0000000..8926783 --- /dev/null +++ b/webExtensionApis/study/src/getPingSize.js @@ -0,0 +1,24 @@ +/* eslint-env commonjs */ + +/** + * Calculate the size of a ping. + * + * @param {Object} payload + * The data payload of the ping. + * + * @returns {Number} + * The total size of the ping. + */ +function getPingSize(payload) { + const converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + let utf8Payload = converter.ConvertFromUnicode(JSON.stringify(payload)); + utf8Payload += converter.Finish(); + return utf8Payload.length; +} + +module.exports = { + getPingSize, +}; diff --git a/webExtensionApis/study/src/index.js b/webExtensionApis/study/src/index.js index 50955ed..2ee9629 100644 --- a/webExtensionApis/study/src/index.js +++ b/webExtensionApis/study/src/index.js @@ -10,6 +10,7 @@ import { utilsLogger, createLogger } from "./logger"; import makeWidgetId from "./makeWidgetId"; import * as testingOverrides from "./testingOverrides"; +import * as dataPermissions from "./dataPermissions"; ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm"); ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm"); @@ -271,6 +272,11 @@ this.study = class extends ExtensionAPI { return studyUtils.info(); }, + /* Object of current dataPermissions (shield enabled true/false, pioneer enabled true/false) */ + getDataPermissions: async function getDataPermissions() { + return dataPermissions.getDataPermissions(); + }, + /** Send Telemetry using appropriate shield or pioneer methods. * * shield: @@ -309,6 +315,23 @@ this.study = class extends ExtensionAPI { return studyUtils.telemetry(payload); }, + /** Calculate Telemetry using appropriate shield or pioneer methods. + * + * shield: + * - Calculate the size of a ping + * + * pioneer: + * - Calculate the size of a ping that has Pioneer encrypted data + * + * @param {Object} payload Non-nested object with key strings, and key values + * @returns {Promise} The total size of the ping. + */ + calculateTelemetryPingSize: async function calculateTelemetryPingSize( + payload, + ) { + return studyUtils.calculateTelemetryPingSize(payload); + }, + /** Search locally stored telemetry pings using these fields (if set) * * n: @@ -448,6 +471,10 @@ this.study = class extends ExtensionAPI { async getInternals() { return studyUtils._internals; }, + + getInternalTestingOverrides: async function getInternalTestingOverrides() { + return testingOverrides.getInternalTestingOverrides(widgetId); + }, }, }; } diff --git a/webExtensionApis/study/src/studyTypes/pioneer.js b/webExtensionApis/study/src/studyTypes/pioneer.js new file mode 100644 index 0000000..7096dfe --- /dev/null +++ b/webExtensionApis/study/src/studyTypes/pioneer.js @@ -0,0 +1,344 @@ +/* eslint-env commonjs */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "(Pioneer)" }]*/ + +import { utilsLogger } from "../logger"; +import * as dataPermissions from "../dataPermissions"; +import { getPingSize } from "../getPingSize"; + +const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm", + {}, +); +const { TelemetryController } = ChromeUtils.import( + "resource://gre/modules/TelemetryController.jsm", + {}, +); + +const { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator, +); + +import { + setCrypto as joseSetCrypto, + Jose, + JoseJWE, +} from "jose-jwe-jws/dist/jose-commonjs.js"; + +// The public keys used for encryption +import * as PUBLIC_KEYS from "./pioneer.public_keys.json"; + +const PIONEER_ID_PREF = "extensions.pioneer.cachedClientID"; + +const EVENTS = { + INELIGIBLE: "ineligible", + EXPIRED: "expired", + USER_DISABLE: "user-disable", + ENDED_POSITIVE: "ended-positive", + ENDED_NEUTRAL: "ended-neutral", + ENDED_NEGATIVE: "ended-negative", +}; + +// Make crypto available and make jose use it. +Cu.importGlobalProperties(["crypto"]); +joseSetCrypto(crypto); + +/** + * @typedef {Object} Config + * @property {String} studyName + * Unique name of the study. + * + * @property {String?} telemetryEnv + * Optional. Which telemetry environment to send data to. Should be + * either ``"prod"`` or ``"stage"``. Defaults to ``"prod"``. + */ + +/** + * Utilities for making Pioneer Studies. + */ +class PioneerUtils { + /** + * @param {Config} config Object with Pioneer-related configuration as specified above + */ + constructor(config) { + this.config = config; + this.encrypter = null; + this._logger = null; + } + + /** + * @returns {Object} A public key + */ + getPublicKey() { + const env = this.config.telemetryEnv || "prod"; + return PUBLIC_KEYS[env]; + } + + /** + * @returns {void} + */ + setupEncrypter() { + if (this.encrypter === null) { + const pk = this.getPublicKey(); + const rsa_key = Jose.Utils.importRsaPublicKey(pk.key, "RSA-OAEP"); + const cryptographer = new Jose.WebCryptographer(); + this.encrypter = new JoseJWE.Encrypter(cryptographer, rsa_key); + } + } + + /** + * @returns {String} Unique ID for a Pioneer user. + */ + getPioneerId() { + let id = Services.prefs.getCharPref(PIONEER_ID_PREF, ""); + + if (!id) { + // generateUUID adds leading and trailing "{" and "}". strip them off. + id = generateUUID() + .toString() + .slice(1, -1); + Services.prefs.setCharPref(PIONEER_ID_PREF, id); + } + + return id; + } + + /** + * @private + * @param {String} data The data to encrypt + * @returns {String} The encrypted data + */ + async encryptData(data) { + this.setupEncrypter(); + return this.encrypter.encrypt(data); + } + + /** + * Constructs a payload object with encrypted data. + * + * @param {String} schemaName + * The name of the schema to be used for validation. + * + * @param {int} schemaVersion + * The version of the schema to be used for validation. + * + * @param {Object} data + * An object containing data to be encrypted and submitted. + * + * @returns {Object} + * A Telemetry payload object with the encrypted data. + */ + async buildEncryptedPayload(schemaName, schemaVersion, data) { + const pk = this.getPublicKey(); + + return { + encryptedData: await this.encryptData(JSON.stringify(data)), + encryptionKeyId: pk.id, + pioneerId: this.getPioneerId(), + studyName: this.config.studyName, + schemaName, + schemaVersion, + }; + } + + /** + * Encrypts the given data and submits a properly formatted + * Pioneer ping to Telemetry. + * + * @param {String} schemaName + * The name of the schema to be used for validation. + * + * @param {int} schemaVersion + * The version of the schema to be used for validation. + * + * @param {Object} data + * A object containing data to be encrypted and submitted. + * + * @param {Object} options + * An object with additional options for the function. + * + * @param {Boolean} options.force + * A boolean to indicate whether to force submission of the ping. + * + * @returns {String} + * The ID of the ping that was submitted + */ + async submitEncryptedPing(schemaName, schemaVersion, data, options = {}) { + // If the user is no longer opted in we should not be submitting pings. + const isUserOptedIn = await dataPermissions.isUserOptedInToPioneer(); + if (!isUserOptedIn && !options.force) { + return null; + } + + const payload = await this.buildEncryptedPayload( + schemaName, + schemaVersion, + data, + ); + + const telOptions = { + addClientId: true, + addEnvironment: true, + }; + + return TelemetryController.submitExternalPing( + "pioneer-study", + payload, + telOptions, + ); + } + + /** + * Gets an object that is a mapping of all the available events. + * + * @returns {Object} + * An object with all the available events. + */ + getAvailableEvents() { + return EVENTS; + } + + /** + * Submits an encrypted event ping. + * + * @param {String} eventId + * The ID of the event that occured. + * + * @param {Object} options + * An object of options to be passed through to submitEncryptedPing + * + * @returns {String} + * The ID of the event ping that was submitted. + */ + async submitEventPing(eventId, options = {}) { + if (!Object.values(EVENTS).includes(eventId)) { + throw new Error("Invalid event ID."); + } + return this.submitEncryptedPing("event", 1, { eventId }, options); + } +} + +class PioneerStudyType { + /** + * @param {object} studyUtils The studyUtils instance from where this class was instantiated + */ + constructor(studyUtils) { + const studySetup = studyUtils._internals.studySetup; + const Config = { + studyName: studySetup.activeExperimentName, + telemetryEnv: studySetup.telemetry.removeTestingFlag ? "prod" : "stage", + }; + this.pioneerUtils = new PioneerUtils(Config); + this.schemaVersion = 3; // Corresponds to the schema versions used in https://github.com/mozilla-services/mozilla-pipeline-schemas/tree/dev/templates/telemetry/shield-study (and the shield-study-addon, shield-study-error equivalents) + } + + /** + * @returns {Promise} The ID of the event ping that was submitted. + */ + async notifyNotEligible() { + return this.notifyEndStudy(this.EVENTS.INELIGIBLE); + } + + /** + * @param {String?} eventId The ID of the event that occured. + * @returns {Promise} The ID of the event ping that was submitted. + */ + async notifyEndStudy(eventId = EVENTS.ENDED_NEUTRAL) { + return this.pioneerUtils.submitEventPing(eventId, { force: true }); + } + + /** + * @returns {Promise} Unique ID for a Pioneer user. + */ + async getTelemetryId() { + return this.pioneerUtils.getPioneerId(); + } + + /** + * @param {String} bucket The type of telemetry payload + * @param {Object} payload The telemetry payload + * @returns {Promise} The ID of the ping that was submitted + */ + async sendTelemetry(bucket, payload) { + const schemaName = bucket; + return this._telemetry(schemaName, this.schemaVersion, payload); + } + + /** + * Encrypts the given data and submits a properly formatted + * Pioneer ping to Telemetry. + * + * @param {String} schemaName + * The name of the schema to be used for validation. + * + * @param {int} schemaVersion + * The version of the schema to be used for validation. + * + * @param {Object} payload + * A object containing data to be encrypted and submitted. + * + * @returns {Promise} The ID of the ping that was submitted + * @private + */ + async _telemetry(schemaName, schemaVersion, payload) { + const pingId = await this.pioneerUtils.submitEncryptedPing( + schemaName, + schemaVersion, + payload, + ); + if (pingId) { + utilsLogger.debug( + "Pioneer Telemetry sent (encrypted)", + JSON.stringify(payload), + ); + } else { + utilsLogger.debug( + "Pioneer Telemetry not sent due to privacy preferences", + JSON.stringify(payload), + ); + } + return pingId; + } + + /** + * Calculate the size of a ping. + * + * @param {String} bucket The type of telemetry payload + * + * @param {Object} payload + * The data payload of the ping. + * + * @returns {Promise} + * The total size of the ping. + */ + async getPingSize(bucket, payload) { + const schemaName = bucket; + return this.getEncryptedPingSize(schemaName, this.schemaVersion, payload); + } + + /** + * Calculate the size of a ping that has Pioneer encrypted data. + * + * @param {String} schemaName + * The name of the schema to be used for validation. + * + * @param {int} schemaVersion + * The version of the schema to be used for validation. + * + * @param {Object} data + * An object containing data to be encrypted and submitted. + * + * @returns {Promise} + * The total size of the ping. + */ + async getEncryptedPingSize(schemaName, schemaVersion, data) { + return getPingSize( + await this.pioneerUtils.buildEncryptedPayload( + schemaName, + schemaVersion, + data, + ), + ); + } +} + +export default PioneerStudyType; diff --git a/webExtensionApis/study/src/studyTypes/pioneer.public_keys.json b/webExtensionApis/study/src/studyTypes/pioneer.public_keys.json new file mode 100644 index 0000000..07e8f55 --- /dev/null +++ b/webExtensionApis/study/src/studyTypes/pioneer.public_keys.json @@ -0,0 +1,20 @@ +{ + "stage": { + "id": "pioneer-20170905", + "key": { + "e": "AQAB", + "kty": "RSA", + "n": + "3nI-DQ7NoUZCvT348Vi4JfGC1h6R3Qf_yXR0dKM5DmwsuQMxguce6sZ28GWQHJjgbdcs8nTuNQihyVtr9vLsoKUVSmPs_a3QEGXEhTpuTtm7cCb_7HyAlwGtysn2AsdElG8HsDFWlZmiDaHTrTmdLnuk-Z3GRg4nnA4xs4vvUuh0fCVIKoSMFyt3Tkc6IBWJ9X3XrDEbSPrghXV7Cu8LMK3Y4avy6rjEGjWXL-WqIPhiYJcBiFnCcqUCMPvdW7Fs9B36asc_2EQAM5d7BAiBwMjoosSyU6b4JGpI530c3xhqLbX00q1ePCG732cIwp0-bGWV_q0FpQX2M9cNv2Ax4Q" + } + }, + "prod": { + "id": "pioneer-20170905", + "key": { + "e": "AQAB", + "kty": "RSA", + "n": + "_uqWswIJpR-cFdwwtNdAI_B_0sPIyQyBy6hiiQ0GKLF2k1PkN6RaxtbZK8v1_BriYtEgWn3hNzJNbKBWBMFtF5-8OfvxH-hgIIeDmRmeHmynLBBCDVf2HAZYaDXJiM7s6LBubDuoPDc3Ovoj287W7E4LgzsBS0wo3ARIwlKn6x0Dj5tu6CQ5r3t0GKZoSFkiVZA7nke-VC55nlDacIIYAqkMX0dzsBaCRmf2C5JJTP-K14iRLB5VFGZ_vnoZ-Wi1BGRV2TNRl3xl0lFJIcPklFpU3hsnRPiF4y7kenU6OIhJVQMqX1CtCF698k7SFCYJt7r1ymWJE-tv0ZwF9b1MFw" + } + } +} diff --git a/webExtensionApis/study/src/studyTypes/shield.js b/webExtensionApis/study/src/studyTypes/shield.js new file mode 100644 index 0000000..8a4abb2 --- /dev/null +++ b/webExtensionApis/study/src/studyTypes/shield.js @@ -0,0 +1,61 @@ +/* eslint-env commonjs */ + +import { getPingSize } from "../getPingSize"; + +const { TelemetryController } = ChromeUtils.import( + "resource://gre/modules/TelemetryController.jsm", + null, +); +const CID = ChromeUtils.import("resource://gre/modules/ClientID.jsm", {}); +// ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm"); + +// eslint-disable-next-line no-undef +// const { ExtensionError } = ExtensionUtils; + +class ShieldStudyType { + /** + * @param {object} studyUtils The studyUtils instance from where this class was instantiated + */ + constructor(studyUtils) { + // console.log("studyUtils", studyUtils); + } + + /** + * @returns {Promise} The telemetry client id + */ + async getTelemetryId() { + const id = TelemetryController.clientID; + /* istanbul ignore next */ + if (id === undefined) { + return CID.ClientIDImpl._doLoadClientID(); + } + return id; + } + + /** + * @param {String} bucket The type of telemetry payload + * @param {Object} payload The telemetry payload + * @returns {Promise} The ID of the ping that was submitted + */ + async sendTelemetry(bucket, payload) { + const telOptions = { addClientId: true, addEnvironment: true }; + return TelemetryController.submitExternalPing(bucket, payload, telOptions); + } + + /** + * Calculate the size of a ping. + * + * @param {String} bucket The type of telemetry payload + * + * @param {Object} payload + * The data payload of the ping. + * + * @returns {Promise} + * The total size of the ping. + */ + async getPingSize(bucket, payload) { + return getPingSize(payload); + } +} + +export default ShieldStudyType; diff --git a/webExtensionApis/study/src/studyUtils.js b/webExtensionApis/study/src/studyUtils.js index 4757616..08b2c25 100644 --- a/webExtensionApis/study/src/studyUtils.js +++ b/webExtensionApis/study/src/studyUtils.js @@ -5,6 +5,8 @@ import sampling from "./sampling"; import { utilsLogger } from "./logger"; import makeWidgetId from "./makeWidgetId"; +import ShieldStudyType from "./studyTypes/shield"; +import PioneerStudyType from "./studyTypes/pioneer"; /* * Supports the `browser.study` webExtensionExperiment api. @@ -49,11 +51,6 @@ const { ExtensionUtils } = ChromeUtils.import( const { ExtensionError } = ExtensionUtils; // telemetry utils -const CID = ChromeUtils.import("resource://gre/modules/ClientID.jsm", {}); -const { TelemetryController } = ChromeUtils.import( - "resource://gre/modules/TelemetryController.jsm", - null, -); const { TelemetryEnvironment } = ChromeUtils.import( "resource://gre/modules/TelemetryEnvironment.jsm", null, @@ -178,7 +175,7 @@ class StudyUtils { * - isSetup: bool `setup` * - isFirstRun: bool `setup`, based on pref * - studySetup: bool `setup` the config - * - seenTelemetry: object of lists of seen telemetry by bucket + * - seenTelemetry: array of seen telemetry. Fully populated only if studySetup.telemetry.internalTelemetryArchive is true * - prefs: object of all created prefs and their names * - endingRequested: string of ending name * - endingReturns: object with useful ending instructions @@ -225,11 +222,7 @@ class StudyUtils { isSetup: false, isEnding: false, isEnded: false, - seenTelemetry: { - "shield-study": [], - "shield-study-addon": [], - "shield-study-error": [], - }, + seenTelemetry: [], prefs: { firstRunTimestamp: `shield.${widgetId}.firstRunTimestamp`, }, @@ -268,6 +261,15 @@ class StudyUtils { throw new ExtensionError("StudyUtils is already setup"); } guard.it("studySetup", studySetup, "(in studySetup)"); + this._internals.studySetup = studySetup; + + // Different study types treat data and configuration differently + if (studySetup.studyType === "shield") { + this.studyType = new ShieldStudyType(this); + } + if (studySetup.studyType === "pioneer") { + this.studyType = new PioneerStudyType(this); + } function getVariationByName(name, variations) { if (!name) return null; @@ -294,7 +296,6 @@ class StudyUtils { utilsLogger.debug(`setting up: variation ${variation.name}`); this._internals.variation = variation; - this._internals.studySetup = studySetup; this._internals.isSetup = true; // isFirstRun? ever seen before? @@ -318,6 +319,7 @@ class StudyUtils { */ reset() { this._internals = this._createInternals(); + this.studyType = null; this.resetFirstRunTimestamp(); } @@ -399,12 +401,7 @@ class StudyUtils { * @returns {string} - the telemetry client ID */ async getTelemetryId() { - const id = TelemetryController.clientID; - /* istanbul ignore next */ - if (id === undefined) { - return CID.ClientIDImpl._doLoadClientID(); - } - return id; + return this.studyType.getTelemetryId(); } /** @@ -432,6 +429,16 @@ class StudyUtils { shieldId: this.getShieldId(), delayInMinutes: this.getDelayInMinutes(), }; + const now = new Date(); + const diff = Number(now) - studyInfo.firstRunTimestamp; + utilsLogger.debug( + "Study info date information: now, new Date(firstRunTimestamp), firstRunTimestamp, diff (in minutes), delayInMinutes", + now, + new Date(studyInfo.firstRunTimestamp), + studyInfo.firstRunTimestamp, + diff / 1000 / 60, + studyInfo.delayInMinutes, + ); guard.it("studyInfoObject", studyInfo, "(in studyInfo)"); return studyInfo; } @@ -457,8 +464,7 @@ class StudyUtils { weightedVariations, fraction = null, ) { - // this is the standard arm choosing method - // TODO, allow 'pioneer' algorithm + // this is the standard arm choosing method, used by both shield and pioneer studies if (fraction === null) { // hash the studyName and telemetryId to get the same branch every time. const clientId = await this.getTelemetryId(); @@ -733,32 +739,39 @@ class StudyUtils { } utilsLogger.debug(`telemetry: ${JSON.stringify(payload)}`); - // IF it's a shield-study or error ping, which are few in number - if (bucket === "shield-study" || bucket === "shield-study-error") { - this._internals.seenTelemetry[bucket].push(payload); - } + let pingId; // during development, don't actually send if (!this.telemetryConfig.send) { utilsLogger.debug("NOT sending. `telemetryConfig.send` is false"); - return false; + pingId = false; + } else { + pingId = await this.studyType.sendTelemetry(bucket, payload); } - const telOptions = { addClientId: true, addEnvironment: true }; - return TelemetryController.submitExternalPing(bucket, payload, telOptions); + // Store a copy of the ping if it's a shield-study or error ping, which are few in number, or if we have activated the internal telemetry archive configuration + if ( + bucket === "shield-study" || + bucket === "shield-study-error" || + this.telemetryConfig.internalTelemetryArchive + ) { + this._internals.seenTelemetry.push({ id: pingId, payload }); + } + + return pingId; } /** * Validates and submits telemetry pings from the add-on; mostly from * webExtension messages. - * @param {Object} data - the data to send as part of the telemetry packet + * @param {Object} payload - the data to send as part of the telemetry packet * @returns {Promise|boolean} - see StudyUtils._telemetry */ - async telemetry(data) { + async telemetry(payload) { this.throwIfNotSetup("telemetry"); - utilsLogger.debug(`telemetry ${JSON.stringify(data)}`); + utilsLogger.debug(`telemetry ${JSON.stringify(payload)}`); const toSubmit = { - attributes: data, + attributes: payload, }; return this._telemetry(toSubmit, "shield-study-addon"); } @@ -771,6 +784,25 @@ class StudyUtils { telemetryError(errorReport) { return this._telemetry(errorReport, "shield-study-error"); } + + /** Calculate Telemetry using appropriate shield or pioneer methods. + * + * shield: + * - Calculate the size of a ping + * + * pioneer: + * - Calculate the size of a ping that has Pioneer encrypted data + * + * @param {Object} payload Non-nested object with key strings, and key values + * @returns {Promise} The total size of the ping. + */ + async calculateTelemetryPingSize(payload) { + this.throwIfNotSetup("calculateTelemetryPingSize"); + const toSubmit = { + attributes: payload, + }; + return this.studyType.getPingSize(toSubmit, "shield-study-addon"); + } } // TODO, use the usual es6 exports diff --git a/webExtensionApis/study/src/telemetry.js b/webExtensionApis/study/src/telemetry.js index 6acceb3..5cef114 100644 --- a/webExtensionApis/study/src/telemetry.js +++ b/webExtensionApis/study/src/telemetry.js @@ -53,8 +53,6 @@ async function searchTelemetryArchive(TelemetryArchive, searchTelemetryQuery) { return Promise.all(pingData); } -// TODO pings report, from the utility add-on - module.exports = { searchTelemetryArchive, }; diff --git a/webExtensionApis/study/src/testingOverrides.js b/webExtensionApis/study/src/testingOverrides.js index 8769a4e..1b85132 100644 --- a/webExtensionApis/study/src/testingOverrides.js +++ b/webExtensionApis/study/src/testingOverrides.js @@ -25,3 +25,10 @@ export function listPreferences(widgetId) { `extensions.${widgetId}.test.expired`, ]; } + +export function getInternalTestingOverrides(widgetId) { + const internalTestingOverrides = {}; + internalTestingOverrides.studyType = + Preferences.get(`extensions.${widgetId}.test.studyType`) || null; + return internalTestingOverrides; +}