Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* Any copyright is dedicated to the Public Domain.
"use strict";
const { Schemas } = ChromeUtils.importESModule(
"resource://gre/modules/Schemas.sys.mjs"
);
ChromeUtils.defineESModuleGetters(this, {
ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetters(this, {
aomStartup: [
"@mozilla.org/addons/addon-manager-startup;1",
"amIAddonManagerStartup",
],
});
AddonTestUtils.init(this);
AddonTestUtils.overrideCertDB();
Services.scriptloader.loadSubScript(
Services.io.newFileURI(do_get_file("head_dnr.js")).spec,
this
);
const EXT_ID = "test-dnr-store-startup-cache@test-extension";
const TEMP_EXT_ID = "test-dnr-store-temporarily-installed@test-extension";
// Test rulesets should include fields that are special cased during Rule object deserialization
// from the plain objects loaded from the StartupCache data objects.
// In particular regexFilter are included to make sure that RuleValidator.deserializeRule is
// internally compiling the regexFilter and storing it into the RuleCondition instance as expected
// (implicitly asserted internally by the test helper assertDNRStoreData in head_dnr.js).
const RULESET_1_DATA = [
getDNRRule({
id: 1,
action: { type: "allow" },
condition: { resourceTypes: ["main_frame"], regexFilter: "http://from/$" },
}),
getDNRRule({
id: 2,
action: { type: "allow" },
condition: {
resourceTypes: ["main_frame"],
regexFilter: "http://from2/$",
isUrlFilterCaseSensitive: true,
},
}),
];
const RULESET_2_DATA = [
getDNRRule({
action: { type: "block" },
condition: { resourceTypes: ["main_frame", "script"] },
}),
];
function getDNRExtension({
id = EXT_ID,
version = "1.0",
useAddonManager = "permanent",
background,
rule_resources,
declarative_net_request,
files,
}) {
// Omit declarative_net_request if rule_resources isn't defined
// (because declarative_net_request fails the manifest validation
// if rule_resources is missing).
const dnr = rule_resources ? { rule_resources } : undefined;
return {
background,
useAddonManager,
manifest: {
manifest_version: 3,
version,
permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
// Needed to make sure the upgraded extension will have the same id and
// same uuid (which is mapped based on the extension id).
browser_specific_settings: {
gecko: { id },
},
declarative_net_request: declarative_net_request
? { ...declarative_net_request, ...(dnr ?? {}) }
: dnr,
},
files,
};
}
add_setup(async () => {
Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
Services.prefs.setBoolPref("extensions.dnr.enabled", true);
Services.prefs.setBoolPref("extensions.dnr.feedback", true);
setupTelemetryForTests();
await ExtensionTestUtils.startAddonManager();
});
add_task(async function test_dnr_startup_cache_save_and_load() {
resetTelemetryData();
const rule_resources = [
{
id: "ruleset_1",
enabled: true,
path: "ruleset_1.json",
},
{
id: "ruleset_2",
enabled: false,
path: "ruleset_2.json",
},
];
const files = {
"ruleset_1.json": JSON.stringify(RULESET_1_DATA),
"ruleset_2.json": JSON.stringify(RULESET_2_DATA),
};
let dnrStore = ExtensionDNRStore._getStoreForTesting();
let sandboxStoreSpies = sinon.createSandbox();
const spyScheduleCacheDataSave = sandboxStoreSpies.spy(
dnrStore,
"scheduleCacheDataSave"
);
const extension = ExtensionTestUtils.loadExtension(
getDNRExtension({ rule_resources, files })
);
const temporarilyInstalledExt = ExtensionTestUtils.loadExtension(
getDNRExtension({
id: TEMP_EXT_ID,
useAddonManager: "temporary",
rule_resources,
files,
})
);
assertDNRTelemetryMetricsNoSamples(
[
{
metric: "validateRulesTime",
mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
mirroredType: "histogram",
},
],
"before any test extensions have been loaded"
);
await temporarilyInstalledExt.startup();
await extension.startup();
info(
"Wait for DNR initialization completed for the temporarily installed extension"
);
await ExtensionDNR.ensureInitialized(temporarilyInstalledExt.extension);
info(
"Wait for DNR initialization completed for the permanently installed extension"
);
await ExtensionDNR.ensureInitialized(extension.extension);
assertDNRTelemetryMetricsSamplesCount(
[
{
metric: "validateRulesTime",
mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
mirroredType: "histogram",
expectedSamplesCount: 2,
},
],
"after two test extensions have been loaded"
);
Assert.equal(
spyScheduleCacheDataSave.callCount,
1,
"Expect ExtensionDNRStore scheduleCacheDataSave method to have been called once"
);
sandboxStoreSpies.restore();
const extUUID = extension.uuid;
const { cacheFile } = dnrStore.getFilePaths(extUUID);
await assertDNRStoreData(dnrStore, extension, {
ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA),
});
assertDNRTelemetryMetricsNoSamples(
[
{
metric: "startupCacheWriteTime",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_MS",
mirroredType: "histogram",
},
{
metric: "startupCacheWriteSize",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES",
mirroredType: "histogram",
},
// Expected no startup cache file to be loaded or used for a newly installed extension.
{
metric: "startupCacheReadSize",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES",
mirroredType: "histogram",
},
{
metric: "startupCacheReadTime",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS",
mirroredType: "histogram",
},
{
metric: "startupCacheEntries",
label: "miss",
mirroredName: "extensions.apis.dnr.startup_cache_entries",
mirroredType: "keyedScalar",
},
{
metric: "startupCacheEntries",
label: "hit",
mirroredName: "extensions.apis.dnr.startup_cache_entries",
mirroredType: "keyedScalar",
},
],
"on loading dnr rules for newly installed extension"
);
await dnrStore.waitSaveCacheDataForTesting();
assertDNRTelemetryMetricsSamplesCount(
[
{
metric: "startupCacheWriteTime",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_MS",
mirroredType: "histogram",
expectedSamplesCount: 1,
},
{
metric: "startupCacheWriteSize",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES",
mirroredType: "histogram",
expectedSamplesCount: 1,
},
],
"after writing DNR startup cache data to disk"
);
ok(
await IOUtils.exists(cacheFile),
"Expect the DNR store startupCache file exist"
);
const assertDNRStoreDataLoadOnStartup = async ({
expectLoadedFromCache,
expectClearLastUpdateTagPref,
}) => {
info(
`Mock browser restart and assert DNR rules ${
expectLoadedFromCache ? "NOT " : ""
}going through Schemas.normalize`
);
await AddonTestUtils.promiseShutdownManager();
// Recreate the DNR store to more easily mock its initial state after a browser restart.
dnrStore = ExtensionDNRStore._recreateStoreForTesting();
const StoreData = ExtensionDNRStore._getStoreDataClassForTesting();
let sandbox = sinon.createSandbox();
const schemasNormalizeSpy = sandbox.spy(Schemas, "normalize");
const ruleValidatorAddRulesSpy = sandbox.spy(
ExtensionDNR.RuleValidator.prototype,
"addRules"
);
const deserializeRuleSpy = sandbox.spy(
ExtensionDNR.RuleValidator,
"deserializeRule"
);
const clearLastUpdateTagPrefSpy = sandbox.spy(
StoreData,
"clearLastUpdateTagPref"
);
const scheduleCacheDataSaveSpy = sandbox.spy(
dnrStore,
"scheduleCacheDataSave"
);
resetTelemetryData();
await AddonTestUtils.promiseStartupManager();
await extension.awaitStartup();
await ExtensionDNR.ensureInitialized(extension.extension);
if (expectLoadedFromCache) {
assertDNRTelemetryMetricsSamplesCount(
[
{
metric: "startupCacheReadSize",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES",
mirroredType: "histogram",
expectedSamplesCount: 1,
},
{
metric: "startupCacheReadTime",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS",
mirroredType: "histogram",
expectedSamplesCount: 1,
},
],
"after app startup and expected startup cache hit"
);
assertDNRTelemetryMetricsGetValueEq(
[
{
metric: "startupCacheEntries",
label: "hit",
expectedGetValue: 1,
mirroredName: "extensions.apis.dnr.startup_cache_entries",
mirroredType: "keyedScalar",
},
],
"after app startup and expected startup cache hit"
);
assertDNRTelemetryMetricsNoSamples(
[
{
metric: "validateRulesTime",
mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
mirroredType: "histogram",
},
{
metric: "startupCacheEntries",
label: "miss",
mirroredName: "extensions.apis.dnr.startup_cache_entries",
mirroredType: "keyedScalar",
},
],
"after DNR store loaded startup cache data"
);
} else {
assertDNRTelemetryMetricsSamplesCount(
[
{
metric: "validateRulesTime",
mirroredName: "WEBEXT_DNR_VALIDATE_RULES_MS",
mirroredType: "histogram",
expectedSamplesCount: 1,
},
{
metric: "startupCacheReadSize",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_BYTES",
mirroredType: "histogram",
expectedSamplesCount: 1,
},
{
metric: "startupCacheReadTime",
mirroredName: "WEBEXT_DNR_STARTUPCACHE_READ_MS",
mirroredType: "histogram",
expectedSamplesCount: 1,
},
],
"after app startup and expected startup cache miss"
);
assertDNRTelemetryMetricsGetValueEq(
[
{
metric: "startupCacheEntries",
label: "miss",
expectedGetValue: 1,
mirroredName: "extensions.apis.dnr.startup_cache_entries",
mirroredType: "keyedScalar",
},
],
"after app startup and expected startup cache miss"
);
assertDNRTelemetryMetricsNoSamples(
[
{
metric: "startupCacheEntries",
label: "hit",
mirroredName: "extensions.apis.dnr.startup_cache_entries",
mirroredType: "keyedScalar",
},
],
"after DNR store loaded startup cache data"
);
}
Assert.equal(
scheduleCacheDataSaveSpy.called,
!expectLoadedFromCache,
"scheduleCacheDataSave to not be called when the extension DNR rules are initialized from startup cache data"
);
Assert.equal(
clearLastUpdateTagPrefSpy.callCount,
expectClearLastUpdateTagPref ? 1 : 0,
"Expect clearLastUpdateTagPrefSpy to have been called the expected number of times"
);
if (expectClearLastUpdateTagPref === true) {
Assert.ok(
clearLastUpdateTagPrefSpy.calledWith(extension.uuid),
"Expect clearLastUpdateTagPrefSpy to have been called with the test extension uuid"
);
}
Assert.equal(
schemasNormalizeSpy.calledWith(
sinon.match.any,
sinon.match("declarativeNetRequest.Rule"),
sinon.match.any
),
!expectLoadedFromCache,
`Expect DNR rules to ${
expectLoadedFromCache ? "NOT " : ""
}be going through Schemas.normalize`
);
Assert.equal(
ruleValidatorAddRulesSpy.called,
!expectLoadedFromCache,
`Expect DNR rules to ${
expectLoadedFromCache ? "NOT " : ""
}be going through RuleValidator addRules`
);
Assert.equal(
deserializeRuleSpy.called,
expectLoadedFromCache,
`Expect RuleValidator.deserializeRule to ${
expectLoadedFromCache ? "NOT " : ""
}be called to convert StartupCache data back into Rule class instances`
);
await assertDNRStoreData(dnrStore, extension, {
ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA),
});
sandbox.restore();
};
const expectedLastUpdateTag = ExtensionDNRStore._getLastUpdateTag(
extension.uuid
);
Assert.ok(
typeof expectedLastUpdateTag == "string" && !!expectedLastUpdateTag.length,
`Expect lastUpdateTag for ${extension.id} to be set to a non empty string: ${expectedLastUpdateTag}`
);
Assert.equal(
ExtensionDNRStore._getLastUpdateTag(temporarilyInstalledExt.uuid),
null,
`Expect no lastUpdateTag value set for temporarily installed extensions`
);
await assertDNRStoreDataLoadOnStartup({
expectLoadedFromCache: true,
expectClearLastUpdateTagPref: false,
});
{
const { buffer } = await IOUtils.read(cacheFile);
const decodedData = aomStartup.decodeBlob(buffer);
Assert.equal(
expectedLastUpdateTag,
decodedData?.cacheData.get(extension.uuid).lastUpdateTag,
"Expect cacheData entry's lastUpdateTag to match the value stored in the related pref"
);
Assert.equal(
decodedData?.cacheData.has(temporarilyInstalledExt.uuid),
false,
"Expect no cache data entry for temporarily installed extensions"
);
info("Confirm startupCache data dropped if last tag pref value mismatches");
ExtensionDNRStore._storeLastUpdateTag(
extension.uuid,
"mismatching-tag-value"
);
Assert.notEqual(
ExtensionDNRStore._getLastUpdateTag(extension.uuid),
decodedData?.cacheData.get(extension.uuid).lastUpdateTag,
"Expect cacheData.lastDNRStoreUpdateTag to NOT match the tampered value stored in the related pref"
);
}
await assertDNRStoreDataLoadOnStartup({
expectLoadedFromCache: false,
expectClearLastUpdateTagPref: true,
});
info(
"Verify that startupCache data mismatching with the StoreData schema version is being dropped"
);
await dnrStore.waitSaveCacheDataForTesting();
await assertDNRStoreDataLoadOnStartup({
expectLoadedFromCache: true,
expectClearLastUpdateTagPref: false,
});
{
info("Tamper the StoreData version in the startupCache data");
const { buffer } = await IOUtils.read(cacheFile);
const decodedData = aomStartup.decodeBlob(buffer);
decodedData.cacheData.get(extUUID).schemaVersion = -1;
await IOUtils.write(
cacheFile,
new Uint8Array(aomStartup.encodeBlob(decodedData))
);
}
await assertDNRStoreDataLoadOnStartup({
expectLoadedFromCache: false,
expectClearLastUpdateTagPref: true,
});
info(
"Verify that startupCache data mismatching with the extension version is being dropped"
);
await dnrStore.waitSaveCacheDataForTesting();
await assertDNRStoreDataLoadOnStartup({
expectLoadedFromCache: true,
expectClearLastUpdateTagPref: false,
});
{
info("Tamper the extension version in the startupCache data");
const { buffer } = await IOUtils.read(cacheFile);
const decodedData = aomStartup.decodeBlob(buffer);
decodedData.cacheData.get(extUUID).extVersion = "0.1";
await IOUtils.write(
cacheFile,
new Uint8Array(aomStartup.encodeBlob(decodedData))
);
}
await assertDNRStoreDataLoadOnStartup({
expectLoadedFromCache: false,
expectClearLastUpdateTagPref: true,
});
await extension.unload();
Assert.equal(
ExtensionDNRStore._getLastUpdateTag(extension.uuid),
null,
"LastUpdateTag pref should have been removed after addon uninstall"
);
});
add_task(async function test_detect_and_reschedule_save_cache_on_new_changes() {
const rule_resources = [
{
id: "ruleset_1",
enabled: true,
path: "ruleset_1.json",
},
];
const files = {
"ruleset_1.json": JSON.stringify(RULESET_1_DATA),
};
let dnrStore = ExtensionDNRStore._getStoreForTesting();
let sandboxStore = sinon.createSandbox();
const spyScheduleCacheDataSave = sandboxStore.spy(
dnrStore,
"scheduleCacheDataSave"
);
let extension;
const tamperedLastUpdateTag = Services.uuid.generateUUID().toString();
let resolvePromiseSaveCacheRescheduled;
let promiseSaveCacheRescheduled = new Promise(resolve => {
resolvePromiseSaveCacheRescheduled = resolve;
});
const realDetectStartupCacheDataChanged =
dnrStore.detectStartupCacheDataChanged.bind(dnrStore);
const stubDetectCacheDataChanges = sandboxStore.stub(
dnrStore,
"detectStartupCacheDataChanged"
);
stubDetectCacheDataChanges.callsFake(seenLastUpdateTags => {
const extData = dnrStore._data.get(extension.extension.uuid);
Assert.ok(extData, "Got StoreData instance for the test extension");
Assert.ok(
typeof extData.lastUpdateTag === "string" &&
!!extData.lastUpdateTag.length,
"Expect a non empty lastUpdateTag assigned to the extension StoreData"
);
Assert.deepEqual(
Array.from(seenLastUpdateTags),
[extData.lastUpdateTag],
"Expects the extension storeData lastUpdateTag to have been seen"
);
if (stubDetectCacheDataChanges.callCount == 1) {
Assert.notEqual(
extData.lastUpdateTag,
tamperedLastUpdateTag,
"New tampered lastUpdateTag should not be equal to the one already set"
);
extData.lastUpdateTag = tamperedLastUpdateTag;
Assert.equal(
realDetectStartupCacheDataChanged(seenLastUpdateTags),
true,
"Expect dnrStore.detectStartupCacheDataChanged to detect a change"
);
return true;
}
Assert.equal(
realDetectStartupCacheDataChanged(seenLastUpdateTags),
false,
"Expect dnrStore.detectStartupCacheDataChanged to NOT have detected any change"
);
Promise.resolve().then(resolvePromiseSaveCacheRescheduled);
return false;
});
extension = ExtensionTestUtils.loadExtension(
getDNRExtension({
id: "test-reschedule-save-on-detected-changes@test",
rule_resources,
files,
})
);
await extension.startup();
info(
"Wait for DNR initialization completed for the permanently installed extension"
);
await ExtensionDNR.ensureInitialized(extension.extension);
info("Wait for the saveCacheDataNow task to have been rescheduled");
await promiseSaveCacheRescheduled;
Assert.equal(
spyScheduleCacheDataSave.callCount,
2,
"Expect ExtensionDNRStore scheduleCacheDataSave method to have been called twice"
);
Assert.equal(
stubDetectCacheDataChanges.callCount,
2,
"Expect ExtensionDNRStore detectStartupCacheDataChanged method to have been called twice"
);
sandboxStore.restore();
await extension.unload();
});