Files
sousa-gecko/toolkit/components/content-classifier/test/browser/head.js
T

341 lines
12 KiB
JavaScript

"use strict";
const { UrlClassifierTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/UrlClassifierTestUtils.sys.mjs"
);
const { RemoteSettings } = ChromeUtils.importESModule(
"resource://services-settings/remote-settings.sys.mjs"
);
const TEST_DOMAIN = "https://example.net/";
const TEST_BLOCKED_3RD_PARTY_DOMAIN = "https://example.org/";
const TEST_ANNOTATED_3RD_PARTY_DOMAIN = "https://example.com/";
const TEST_PATH = "browser/toolkit/components/content-classifier/test/browser/";
const TEST_TOP_PAGE = TEST_DOMAIN + TEST_PATH + "page.html";
const BLOCK_LIST_URL = "https://example.net/" + TEST_PATH + "block_list.txt";
const ANNOTATE_LIST_URL =
"https://example.net/" + TEST_PATH + "annotate_list.txt";
const COLLECTION_NAME = "content-classifier-lists";
// Must match NS_CONTENT_CLASSIFIER_FILTER_LISTS_LOADED_TOPIC in
// nsIContentClassifierService.idl.
const LISTS_LOADED_TOPIC = "test-content-classifier-filter-lists-loaded";
async function loadThirdPartyImage(browser, domain) {
let imageURL =
domain +
"browser/toolkit/components/antitracking/test/browser/raptor.jpg?" +
Math.random();
return SpecialPowers.spawn(browser, [imageURL], async url => {
let img = new content.Image();
img.src = url;
return new content.Promise(resolve => {
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
});
});
}
async function assertImageBlocked(browser, domain, msg) {
let loaded = await loadThirdPartyImage(browser, domain);
ok(!loaded, msg);
}
async function assertImageLoaded(browser, domain, msg) {
let loaded = await loadThirdPartyImage(browser, domain);
ok(loaded, msg);
}
// content: string-encoded filter list content (may include CRLF etc.).
async function makeListRecordFromContent(id, name, content) {
let encoder = new TextEncoder();
let bytes = encoder.encode(content);
let blob = new Blob([bytes]);
let buffer = await blob.arrayBuffer();
let hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
let hashArray = Array.from(new Uint8Array(hashBuffer));
let hash = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
return {
record: {
id,
Name: name,
last_modified: Date.now(),
attachment: {
hash,
size: bytes.length,
filename: name + ".txt",
location: "main-workspace/content-classifier-lists/" + id + ".txt",
mimetype: "text/plain",
},
},
blob,
};
}
async function makeListRecord(id, name, rules) {
return makeListRecordFromContent(id, name, rules.join("\n") + "\n");
}
// Populate the RS database with one or more lists. Each entry is
// { id, name, rules } or { id, name, content } (raw string content).
async function populateMultipleRS(db, lists) {
let records = [];
let attachments = [];
for (let entry of lists) {
let { id, name } = entry;
let made = entry.content
? await makeListRecordFromContent(id, name, entry.content)
: await makeListRecord(id, name, entry.rules);
records.push(made.record);
attachments.push({
id: made.record.id,
record: made.record,
blob: made.blob,
});
}
await db.importChanges({}, Date.now(), records, { clear: true });
for (let { id, record, blob } of attachments) {
await db.saveAttachment(id, { record, blob });
}
return records;
}
async function populateRS(db, id, name, rules) {
let [record] = await populateMultipleRS(db, [{ id, name, rules }]);
return record;
}
// Wait until the content-classifier filter lists have settled after a
// change. The C++ service rebuilds engines off the main thread and fires
// LISTS_LOADED_TOPIC once a rebuild completes.
//
// Key invariant we rely on: every change bumps a global generation counter
// synchronously, and a rebuild only fires the topic if its generation is
// still the latest when it finishes. So a *batch* of changes applied
// synchronously (all the prefs in one pushPrefEnv, or one RS sync) leaves
// exactly one un-superseded rebuild, i.e. exactly one notification, and it
// carries the final state. That lets callers who make a single such batch
// wait for exactly one notification and stop - no quiet-window drain, which
// is pure dead time and dominated test runtime.
//
// minNotifies How many notifications to wait for before settling. A
// guaranteed-rebuild batch (an RS sync, a real engines-pref
// change) uses 1; best-effort drains, where zero rebuilds is
// a legitimate outcome, use 0.
// quietMs 0 (default) settles as soon as minNotifies is reached.
// A positive value instead waits for that long a gap with no
// further notification - only needed when a single call may
// legitimately produce several rebuilds whose order matters
// and must all be drained (see test_rs_back_to_back).
// timeoutMs Deadlock backstop only: if an expected notification never
// arrives we resolve anyway (after logging) rather than hang.
//
// The minNotifies gate is also what makes this robust under MOZ_CHAOSMODE,
// where a rebuild's notification can arrive long after its trigger: we never
// settle until the rebuild has actually reported in.
async function waitForListsSettled({
quietMs = 0,
minNotifies = 0,
timeoutMs = 30000,
} = {}) {
return new Promise(resolve => {
let quietTimer;
let deadlineTimer;
let notifies = 0;
function finish() {
clearTimeout(quietTimer);
clearTimeout(deadlineTimer);
Services.obs.removeObserver(observer, LISTS_LOADED_TOPIC);
resolve();
}
// Settle once enough notifications have been seen. With quietMs === 0
// that is immediate; otherwise we (re)arm a quiet window so trailing
// rebuilds within quietMs are drained first.
function maybeSettle() {
if (notifies < minNotifies) {
return;
}
if (!quietMs) {
finish();
return;
}
clearTimeout(quietTimer);
quietTimer = setTimeout(finish, quietMs);
}
let observer = () => {
notifies++;
maybeSettle();
};
Services.obs.addObserver(observer, LISTS_LOADED_TOPIC);
// Best-effort drains (minNotifies === 0) settle on the quiet window even
// if no rebuild ever fires, so evaluate the settle condition immediately.
maybeSettle();
deadlineTimer = setTimeout(() => {
if (notifies < minNotifies) {
info(
`waitForListsSettled: timed out after ${timeoutMs}ms with ` +
`${notifies}/${minNotifies} notification(s); resolving anyway`
);
}
finish();
}, timeoutMs);
});
}
// Emit an RS sync and wait for the resulting rebuild to settle. A sync is a
// single synchronous change, so it yields exactly one notification carrying
// the final state (see waitForListsSettled): wait for that one and stop.
async function syncAndWaitForLists(client, records) {
let settled = waitForListsSettled({ minNotifies: 1 });
await client.emit("sync", {
data: { created: records, updated: [], deleted: [] },
});
await settled;
}
// Get the RS client and register cleanup to clear its DB and disable
// the feature after the current test task finishes (pass OR fail,
// including throws). In browser-chrome tests, registerCleanupFunction
// runs after the current task and before the next. Disabling the
// feature here ensures a thrown task cannot leave the C++ service
// with live engines / an initialized RS client that the next task
// would pick up.
function getRSClient() {
let client = RemoteSettings(COLLECTION_NAME);
registerCleanupFunction(async () => {
await SpecialPowers.pushPrefEnv({
set: [
["privacy.trackingprotection.content.protection.enabled", false],
["privacy.trackingprotection.content.annotation.enabled", false],
["privacy.trackingprotection.content.protection.engines", ""],
["privacy.trackingprotection.content.protection.engines.pbmode", ""],
["privacy.trackingprotection.content.annotation.engines", ""],
["privacy.trackingprotection.content.annotation.engines.pbmode", ""],
],
});
await client.db.clear();
});
return client;
}
async function openTestTab() {
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
TEST_TOP_PAGE
);
registerCleanupFunction(() => {
if (tab && tab.parentNode) {
BrowserTestUtils.removeTab(tab);
}
});
return tab;
}
async function openPrivateTab() {
let win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
let tab = await BrowserTestUtils.openNewForegroundTab(
win.gBrowser,
TEST_TOP_PAGE
);
registerCleanupFunction(async () => {
await BrowserTestUtils.closeWindow(win);
});
return tab;
}
// Set the four engines prefs in one call. Empty string disables that
// slot. protectionEnabled / annotationEnabled default to true iff the
// corresponding non-PBM or PBM engines string is non-empty; pass an
// explicit boolean to override (e.g. to set engines while leaving the
// feature disabled).
async function pushEnginePrefs({
protection = "",
pbmProtection = "",
annotation = "",
pbmAnnotation = "",
protectionEnabled,
annotationEnabled,
} = {}) {
if (protectionEnabled === undefined) {
protectionEnabled = Boolean(protection || pbmProtection);
}
if (annotationEnabled === undefined) {
annotationEnabled = Boolean(annotation || pbmAnnotation);
}
await SpecialPowers.pushPrefEnv({
set: [
["privacy.trackingprotection.content.testing", true],
[
"privacy.trackingprotection.content.protection.enabled",
protectionEnabled,
],
["privacy.trackingprotection.content.protection.engines", protection],
[
"privacy.trackingprotection.content.protection.engines.pbmode",
pbmProtection,
],
["privacy.trackingprotection.content.protection.test_list_urls", ""],
[
"privacy.trackingprotection.content.annotation.enabled",
annotationEnabled,
],
["privacy.trackingprotection.content.annotation.engines", annotation],
[
"privacy.trackingprotection.content.annotation.engines.pbmode",
pbmAnnotation,
],
["privacy.trackingprotection.content.annotation.test_list_urls", ""],
],
});
// No wait here: these prefs are one synchronous batch, so generation
// suppression collapses them to a single (final-state) rebuild whose
// notification - if it even fires before the caller's syncAndWaitForLists -
// already carries the full pref snapshot. Every caller that then asserts
// engine behavior goes through syncAndWaitForLists, which owns the wait;
// the lone exception leaves the feature disabled (no rebuild to wait for).
}
// Inspect a content-blocking log dump and assert that `origin` has an
// entry whose state-bit equals `state`. Uses Assert.ok so a missing
// origin or missing entry STOPS the surrounding task instead of
// silently falling through.
function assertLogHasState(log, origin, state, msg) {
Assert.ok(log[origin], `content blocking log has entry for ${origin}`);
let entry = log[origin].find(e => e[0] === state);
Assert.ok(entry, msg);
return entry;
}
// Asymmetric with assertLogHasState: uses ok() (records, doesn't throw)
// so a completely missing origin counts as "lacks state" rather than
// aborting the task.
function assertLogLacksState(log, origin, state, msg) {
if (!log[origin]) {
ok(true, msg + " (no log entry for origin)");
return;
}
let entry = log[origin].find(e => e[0] === state);
ok(!entry, msg);
}
// Convenience: fetch the content-blocking log for `browser`, slice the
// trailing slash off `domain`, and assert (or assert-absence of) `state`.
async function assertHasBlockingState(browser, domain, state, msg) {
let log = JSON.parse(await browser.getContentBlockingLog());
let origin = domain.replace(/\/$/, "");
return assertLogHasState(log, origin, state, msg);
}
async function assertLacksBlockingState(browser, domain, state, msg) {
let log = JSON.parse(await browser.getContentBlockingLog());
let origin = domain.replace(/\/$/, "");
assertLogLacksState(log, origin, state, msg);
}