Fix Firefox account consent for 4.0.6

This commit is contained in:
Anarios
2026-09-07 19:10:02 +02:00
parent e500088e65
commit ee20c7437c
20 changed files with 606 additions and 331 deletions
+12
View File
@@ -0,0 +1,12 @@
# Return YouTube Dislike 4.0.6
What's new since 3.0.0.18:
- **YouTube Shorts support:** see dislike counts and vote on Shorts, with fixes for switching between videos and keeping the buttons in sync.
- **More reliable counts and voting:** fixes for the latest YouTube layouts, stale counts after navigation, and votes that failed to submit.
- **Premium statistics:** explore likes and dislikes over time, compare activity by country, and browse interactive world and US maps.
- **Hide clutter:** keep Like and Dislike visible while moving extra YouTube actions into the More menu.
- **Refreshed controls:** an updated popup, improved display settings, and raw RYD like/dislike totals in the side panel.
- **New: GitHub contributor sign-in**, alongside Patreon access to premium features.
Requires Firefox 140 or newer.
+12 -96
View File
@@ -1,105 +1,21 @@
# AMO 4.0.5 Submission Notes
# AMO 4.0.6 Reviewer Notes
## Files to upload
4.0.6 fixes the `authenticationInfo` consent handling rejected in 4.0.5. Account requests require an explicit grant from
`browser.permissions.getAll().data_collection`. Sign-in requests consent from the user's click; denial blocks account
traffic, and revocation clears the session. Dislike counts work without signing in.
- Add-on package: `return-youtube-dislike-firefox-4.0.5.zip`
- Source package: `return-youtube-dislike-source-4.0.5.zip`
- Privacy policy: copy `Docs/Privacy Policy` into the AMO listing privacy-policy field.
## Backend deployment prerequisite
Deploy the matching backend entitlement change **before releasing extension 4.0.5** or publishing its privacy policy.
The account flow must request only currently entitled Patreon tier IDs for eligibility, with no membership amount,
charge-date/status, lifetime-payment, or patron-status attributes. Verify that behavior and a fresh reviewer-account
login against the deployed backend. The source changes and these notes do not establish that the live backend has
already been updated.
## Notes to reviewer
Version 4.0.5 addresses rejection reference `a424a6ef-1172-4c6c-8028-22187c194255` and the earlier source-code
review concerns.
The Firefox manifest now uses Firefox's built-in data-collection consent system and requires Firefox 140 or later. It
retains the published add-on ID `{762f9885-5a13-4abd-9c77-433dcd38b8fd}` and declares these required data categories:
- `personallyIdentifyingInfo`: a persistent random installation `userId` is registered with the Return YouTube Dislike
API and is included with vote submissions. Optional sign-in also processes provider account identifiers, name,
profile image, and email when returned by GitHub under this category. Patreon does not request or return email.
- `browsingActivity`: the current YouTube `videoId` identifies the video page being viewed and is sent for dislike
lookups and related analytics.
- `websiteContent`: the current YouTube `videoId` and, when available, visible `likeCount` are sent to the API to obtain
and improve the dislike estimate.
- `websiteActivity`: a user-initiated Like, Dislike, or vote removal is sent as the vote `value` with the `videoId` and
random `userId`.
The optional Patreon/GitHub account feature declares `authenticationInfo`. The matching backend determines Patreon
access from currently entitled tier IDs, without requesting or using membership amounts, charge dates or status,
lifetime payments, or patron status. Firefox requests authentication permission directly from the corresponding
sign-in click. Denying it produces no OAuth, session-verification, or premium Bearer-token traffic. Removing it clears
the account session, tears down active premium analytics, and blocks
later account requests. Delayed login and verification responses cannot restore a revoked session. Core dislike counts
continue to work. Browser sync may synchronize extension settings, identifiers, and account sessions when enabled.
Treating tier IDs as account entitlements covered by account identity and authentication declarations is our data-category
interpretation; Mozilla has not approved this classification. Paid access remains disclosed in the listing. The
`financialAndPaymentInfo` category is omitted because the matching backend no longer requests or processes the financial
and payment attributes used by the earlier implementation. Removing an obsolete financial-data grant alone does not
sign the user out; revoking authentication consent does.
Firefox private-window access is disabled so private browsing data is not retained. The popup no longer makes an
unrelated remote version check or server-status probe, and packaged extension pages no longer request remote fonts.
The supplied source archive contains the lockfile, exact production inputs, and build instructions. From its root,
install Node.js 22.17.0 and npm 10.8.2, then run:
Build the supplied source with Node.js 22.17.0 and npm 10.8.2:
```sh
node scripts/build-firefox-source.mjs
```
The generated comparison directory is `Extensions/combined/dist/firefox`. The build script checks the version,
manifest consent contract, required `menu-fixer.js`, bundle size, absence of source-map references, and source-input
receipt. Dependencies are installed only through npm from the npm registry.
Output: `Extensions/combined/dist/firefox`. The source rebuild matches the submitted package byte for byte.
## Functional review path
To test premium statistics, select **Login with Patreon**, accept the optional consent, and use the account below.
Open a YouTube video to view the statistics. Revoke authentication consent in `about:addons` to verify sign-out.
1. Install in a fresh Firefox 140-or-newer profile and accept the required data categories.
2. Open a public YouTube video and confirm the dislike estimate appears.
3. Open the extension popup. Decline either Patreon or GitHub sign-in consent and confirm the account remains signed
out while ordinary dislike counts continue working.
4. Grant the optional authentication permission and sign in with the supplied reviewer account. Exercise the premium
analytics panel using that account's active premium access.
5. Revoke authentication data under `about:addons` > Return YouTube Dislike > Permissions and data. Confirm
the account session ends and no premium panel appears on subsequent navigation or after restarting Firefox.
## Listing and reviewer access
Add this disclosure to the listing description:
> Return YouTube Dislike sends the current YouTube video ID and visible like count to its API to display dislike
> estimates. Optional vote submissions include a persistent random installation ID and your selected vote. Optional
> Patreon/GitHub sign-in processes account and membership information. Premium analytics has eligibility requirements,
> including paid Patreon membership tiers. Core dislike counts are free. Firefox asks for required data consent during installation or update and
> requests optional account-data consent when you sign in.
Before submission, deploy the backend entitlement change and complimentary-access grant, then verify a fresh login with the dedicated
Patreon test account. Supply that account's login details in AMO's **Notes for Reviewers**, as described in the
[submission guide](https://extensionworkshop.com/documentation/publish/submitting-an-add-on/). Mozilla requires
reviewer access to account-only functionality. Do not place credentials in this file or in the source archive.
Confirm the listing discloses paid functionality and includes the updated privacy policy.
Include these instructions with the login details:
> This is a dedicated Patreon test account with complimentary premium access granted by our backend. No paid
> subscription is needed. In the extension popup, choose Log in with Patreon, accept the optional account-data
> permission prompt, and complete Patreon authorization using the supplied test account. Open a YouTube video and
> its premium analytics. Complimentary access is available through October 5, 2026 (UTC); contact us if the review
> requires an extension.
## Policy references
- [Built-in data consent and data categories](https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/)
- [Submission and data-transmission requirements](https://extensionworkshop.com/documentation/publish/add-on-policies/)
- [Source-code submission](https://extensionworkshop.com/documentation/publish/source-code-submission/)
Temporary developer installs silently grant required permissions and do not prove the installation/update consent
prompt. Test that prompt using a packaged install in a disposable Firefox Developer Edition or Nightly profile.
```text
Login:
Password:
```
+2 -2
View File
@@ -1,8 +1,8 @@
# Return YouTube Dislike Privacy Policy
Last updated: September 5, 2026
Last updated: September 7, 2026
This policy describes version 4.0.5 and its matching backend release.
This policy describes version 4.0.6 and its matching backend release.
Return YouTube Dislike transmits only the data needed to provide its dislike-count, voting, abuse-prevention, and
optional premium features. It does not sell personal data or use it for advertising.
+2 -2
View File
@@ -21,7 +21,7 @@ associate submitted votes with the same extension installation.
For dislike-count requests: the current video ID and, when available, the visible like count. For registration and vote
submission: the random extension user ID, video ID, selected vote, timestamp, and network address used for abuse
controls. Optional premium features use the account ID, name, email address, profile image, membership information, and
service session token returned by a Patreon or GitHub sign-in initiated by the user. For version 4.0.5 and its matching
service session token returned by a Patreon or GitHub sign-in initiated by the user. For version 4.0.6 and its matching
backend release, Patreon access is determined from currently entitled tier IDs rather than financial information.
### How is my IP stored?
@@ -33,7 +33,7 @@ abuse prevention and aggregate analytics. It is not sold or used for advertising
Premium analytics sign-in is optional and starts only after the user chooses Patreon or GitHub login in the extension.
The OAuth flow returns account details and a Return YouTube Dislike session token used to authenticate premium API
requests. In version 4.0.5 and its matching backend release, Patreon eligibility uses currently entitled tier IDs;
requests. In version 4.0.6 and its matching backend release, Patreon eligibility uses currently entitled tier IDs;
the service does not request or use membership amounts, charge dates or status, lifetime payments, or patron status.
Firefox asks for separate authentication-data consent before account traffic begins. Signing out removes
the stored account details and session from the extension. Revoking Firefox's authentication-data permission also clears the
@@ -103,7 +103,7 @@ describe("generated userscript artifact", () => {
config.output = { ...config.output, path: temporaryDirectory };
await compile(config);
const rebuilt = fs.readFileSync(path.join(temporaryDirectory, config.output.filename), "utf8");
expect(rebuilt).toBe(artifact);
expect(rebuilt.replace(/\r\n/g, "\n")).toBe(artifact.replace(/\r\n/g, "\n"));
} finally {
expectFileUnchanged(receiptPath, receiptBefore);
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
+51 -13
View File
@@ -2,13 +2,6 @@
const fs = require("fs");
const path = require("path");
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
requestAuthenticationDataPermission: jest.fn(),
usesFirefoxDataCollectionConsent: jest.fn(),
}));
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const cachedUser = { fullName: "Test User", imageUrl: "https://example.org/avatar", membershipTier: "none" };
@@ -21,14 +14,19 @@ describe("popup account consent lifecycle", () => {
jest.resetModules();
consent = true;
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
const permissions = require("./src/data-collection-permissions");
permissions.usesFirefoxDataCollectionConsent.mockReturnValue(true);
permissions.hasAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.requestAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.onAuthenticationDataPermissionRemoved.mockImplementation((listener) => (removeConsent = listener));
global.chrome = {
i18n: { getMessage: () => "" },
runtime: { getManifest: () => ({ version: "4.0.5" }), sendMessage: jest.fn() },
runtime: { getManifest: () => require("./manifest-firefox.json"), sendMessage: jest.fn() },
permissions: {
getAll: jest.fn(() => Promise.resolve({ data_collection: consent ? ["authenticationInfo"] : [] })),
contains: jest.fn().mockResolvedValue(true),
request: jest.fn(() => Promise.resolve(consent)),
onRemoved: {
addListener: (listener) => {
removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
},
},
},
identity: { getRedirectURL: jest.fn() },
storage: {
sync: {
@@ -41,6 +39,7 @@ describe("popup account consent lifecycle", () => {
onChanged: { addListener: jest.fn() },
},
};
global.browser = global.chrome;
global.fetch = jest.fn();
require("./popup");
});
@@ -48,6 +47,7 @@ describe("popup account consent lifecycle", () => {
afterEach(() => {
jest.restoreAllMocks();
delete global.chrome;
delete global.browser;
delete global.fetch;
});
@@ -82,6 +82,44 @@ describe("popup account consent lifecycle", () => {
expect(document.getElementById("patreon-user-avatar").hasAttribute("src")).toBe(false);
}
test.each([[], undefined])("does not verify or display cached account data without a grant: %j", async (granted) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], data_collection: granted });
readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.storage.sync.remove).toHaveBeenCalledWith([
"patreonAuthenticated",
"patreonUser",
"patreonSessionToken",
]);
expectLoggedOut();
});
test.each(["patreon-login-btn", "github-login-btn"])(
"does not start %s when Firefox denies authentication consent",
async (button) => {
consent = false;
jest.spyOn(window, "alert").mockImplementation(() => {});
document.getElementById(button).click();
expect(chrome.permissions.request).toHaveBeenCalledWith({ data_collection: ["authenticationInfo"] });
await flushPromises();
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
},
);
test.each(["patreon-login-btn", "github-login-btn"])(
"does not start %s after a request succeeds without a recorded grant",
async (button) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], data_collection: [] });
jest.spyOn(window, "alert").mockImplementation(() => {});
document.getElementById(button).click();
await flushPromises();
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
},
);
test("does not verify or show a cached session whose storage read completes after revocation", async () => {
revoke();
readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
+23 -10
View File
@@ -1,9 +1,4 @@
jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() }));
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
usesFirefoxDataCollectionConsent: jest.fn(),
}));
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const startData = () => {
@@ -45,18 +40,23 @@ describe("background account consent lifecycle", () => {
},
},
});
const permissions = require("./src/data-collection-permissions");
permissions.usesFirefoxDataCollectionConsent.mockReturnValue(true);
permissions.hasAuthenticationDataPermission.mockImplementation(() => Promise.resolve(consent));
permissions.onAuthenticationDataPermissionRemoved.mockImplementation((listener) => (removeConsent = listener));
require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) });
global.__RYD_LIVE_TEST_BUILD__ = false;
global.chrome = {
runtime: {
getManifest: () => ({ version: "4.0.5" }),
getManifest: () => require("./manifest-firefox.json"),
onMessage: { addListener: (listener) => (messageListener = listener) },
onInstalled: { addListener: jest.fn() },
},
permissions: {
getAll: jest.fn(() => Promise.resolve({ data_collection: consent ? ["authenticationInfo"] : [] })),
contains: jest.fn().mockResolvedValue(true),
onRemoved: {
addListener: (listener) => {
removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
},
},
},
identity: {
getRedirectURL: () => "https://extension.example/callback",
launchWebAuthFlow: jest.fn(() => Promise.resolve("https://extension.example/callback?code=test&state=test")),
@@ -108,6 +108,19 @@ describe("background account consent lifecycle", () => {
},
);
test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s sends nothing when Firefox does not expose built-in data consent",
async (message) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], origins: [] });
const response = jest.fn();
messageListener({ message }, {}, response);
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
expect(response).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
},
);
test.each(["patreon_oauth_login", "github_oauth_login"])(
"%s does not open OAuth after consent is revoked during startup",
async (message) => {
@@ -21,18 +21,6 @@ function usesFirefoxDataCollectionConsent() {
return Boolean(manifest?.browser_specific_settings?.gecko?.data_collection_permissions);
}
function callFirefoxPermissionMethod(methodName) {
const permissions = getPermissionsApi();
const method = permissions?.[methodName];
if (typeof method !== "function") return Promise.resolve(false);
try {
return Promise.resolve(method.call(permissions, AUTHENTICATION_DATA_DESCRIPTOR)).then(Boolean, () => false);
} catch (_) {
return Promise.resolve(false);
}
}
function queryBackgroundForAuthenticationDataPermission() {
const runtime = getRuntimeApi();
if (typeof runtime?.sendMessage !== "function") return Promise.resolve(false);
@@ -64,8 +52,22 @@ function queryBackgroundForAuthenticationDataPermission() {
function hasAuthenticationDataPermission({ queryBackground = true } = {}) {
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
if (typeof getPermissionsApi()?.contains === "function") return callFirefoxPermissionMethod("contains");
return queryBackground ? queryBackgroundForAuthenticationDataPermission() : Promise.resolve(false);
const permissions = getPermissionsApi();
if (!permissions) {
return queryBackground ? queryBackgroundForAuthenticationDataPermission() : Promise.resolve(false);
}
if (typeof permissions.getAll !== "function") return Promise.resolve(false);
try {
return Promise.resolve(permissions.getAll()).then(
(granted) =>
Array.isArray(granted?.data_collection) &&
ACCOUNT_DATA_PERMISSIONS.every((permission) => granted.data_collection.includes(permission)),
() => false,
);
} catch (_) {
return Promise.resolve(false);
}
}
function requestAuthenticationDataPermission() {
@@ -73,7 +75,17 @@ function requestAuthenticationDataPermission() {
// Keep this direct request as the first operation in the login click stack.
// Firefox requires optional data-collection consent requests to originate from a user gesture.
return callFirefoxPermissionMethod("request");
const permissions = getPermissionsApi();
if (typeof permissions?.request !== "function") return Promise.resolve(false);
try {
return Promise.resolve(permissions.request(AUTHENTICATION_DATA_DESCRIPTOR)).then(
(granted) => granted === true && hasAuthenticationDataPermission({ queryBackground: false }),
() => false,
);
} catch (_) {
return Promise.resolve(false);
}
}
function authenticationDataPermissionWasRemoved(removedPermissions) {
@@ -31,7 +31,7 @@ describe("Firefox data-collection permissions", () => {
});
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: { request },
permissions: { request, getAll: jest.fn().mockResolvedValue({ data_collection: ["authenticationInfo"] }) },
};
const result = requestAuthenticationDataPermission();
@@ -42,18 +42,76 @@ describe("Firefox data-collection permissions", () => {
});
it("checks the existing Firefox grant without requesting it", async () => {
const contains = jest.fn(() => Promise.resolve(false));
const getAll = jest.fn().mockResolvedValue({ permissions: ["identity"], origins: [], data_collection: [] });
const contains = jest.fn().mockResolvedValue(true);
const request = jest.fn();
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: { contains, request },
permissions: { getAll, contains, request },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(contains).toHaveBeenCalledWith({ data_collection: ["authenticationInfo"] });
expect(getAll).toHaveBeenCalledWith();
expect(contains).not.toHaveBeenCalled();
expect(request).not.toHaveBeenCalled();
});
it.each([{}, { data_collection: [] }, { data_collection: ["financialAndPaymentInfo"] }])(
"does not accept a successful request without a recorded authentication grant: %j",
async (grantedPermissions) => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
request: jest.fn().mockResolvedValue(true),
getAll: jest.fn().mockResolvedValue(grantedPermissions),
contains: jest.fn().mockResolvedValue(true),
},
};
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
},
);
it("does not let an existing grant override the user's denied request", async () => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
request: jest.fn().mockResolvedValue(false),
getAll: jest.fn().mockResolvedValue({ data_collection: ["authenticationInfo"] }),
},
};
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
expect(browser.permissions.getAll).not.toHaveBeenCalled();
});
it("reads the current grant again after it is revoked without a cached result", async () => {
const getAll = jest
.fn()
.mockResolvedValueOnce({ data_collection: ["authenticationInfo"] })
.mockResolvedValueOnce({ data_collection: [] });
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: { getAll, contains: jest.fn().mockResolvedValue(true) },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(true);
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
});
it("fails closed when getAll throws synchronously", async () => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
getAll: () => {
throw new Error("unavailable");
},
},
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
});
it("leaves Chrome behavior unchanged", async () => {
const request = jest.fn();
global.chrome = {
@@ -78,19 +136,20 @@ describe("Firefox data-collection permissions", () => {
it.each([
{ granted: ["authenticationInfo"], expected: true },
{ granted: ["financialAndPaymentInfo"], expected: false },
])(
"checks authentication consent independently of obsolete financial grants: $granted",
async ({ granted, expected }) => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
contains: ({ data_collection }) =>
Promise.resolve(data_collection.every((permission) => granted.includes(permission))),
},
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(expected);
},
);
{ granted: [], expected: false },
{ granted: undefined, expected: false },
{ granted: null, expected: false },
{ granted: "authenticationInfo", expected: false },
])("requires an explicit authentication grant in getAll: $granted", async ({ granted, expected }) => {
global.browser = {
runtime: { getManifest: () => firefoxManifest },
permissions: {
contains: jest.fn().mockResolvedValue(true),
getAll: jest.fn().mockResolvedValue({ permissions: ["identity"], origins: [], data_collection: granted }),
},
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(expected);
});
it("does not end the account session when only an obsolete financial grant is removed", () => {
let removedListener;
@@ -124,6 +183,30 @@ describe("Firefox data-collection permissions", () => {
await expect(hasAuthenticationDataPermission({ queryBackground: false })).resolves.toBe(false);
});
it("does not fall back to contains or the background when getAll is unavailable", async () => {
const sendMessage = jest.fn().mockResolvedValue({ granted: true });
const contains = jest.fn().mockResolvedValue(true);
global.browser = {
runtime: { getManifest: () => firefoxManifest, sendMessage },
permissions: { contains },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(contains).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
});
it("does not fall back to a positive background result after getAll fails", async () => {
const sendMessage = jest.fn().mockResolvedValue({ granted: true });
global.browser = {
runtime: { getManifest: () => firefoxManifest, sendMessage },
permissions: { getAll: jest.fn().mockRejectedValue(new Error("unavailable")) },
};
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
expect(sendMessage).not.toHaveBeenCalled();
});
it("fails closed on permission API and background failures", async () => {
global.browser = {
runtime: { getManifest: () => firefoxManifest, sendMessage: () => Promise.reject(new Error("unavailable")) },
@@ -131,7 +214,7 @@ describe("Firefox data-collection permissions", () => {
await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
global.browser.permissions = {
contains: () => Promise.reject(new Error("unavailable")),
getAll: () => Promise.reject(new Error("unavailable")),
request: () => {
throw new Error("outside gesture");
},
@@ -255,6 +255,23 @@ describe("premiumAnalytics", () => {
expect(analyticsState.sessionActive).toBe(false);
});
it.each([[], undefined])("does not send a bearer token without an explicit Firefox grant: %j", async (granted) => {
const actualConsent = jest.requireActual("../data-collection-permissions");
chrome.runtime = { getManifest: () => require("../../manifest-firefox.json") };
chrome.permissions = {
contains: jest.fn().mockResolvedValue(true),
getAll: jest.fn().mockResolvedValue({ permissions: ["identity"], data_collection: granted }),
};
mockUsesFirefoxDataCollectionConsent.mockImplementationOnce(actualConsent.usesFirefoxDataCollectionConsent);
mockHasAuthenticationDataPermission.mockImplementationOnce(actualConsent.hasAuthenticationDataPermission);
await requestAnalytics();
expect(fetch).not.toHaveBeenCalled();
expect(analyticsState.sessionToken).toBeNull();
expect(analyticsState.sessionActive).toBe(false);
});
it("ignores an analytics response from a session that was signed out", async () => {
let completeRequest;
const response = await fetch();
+292 -135
View File
@@ -9,6 +9,7 @@ const crypto = require("node:crypto");
// Run after the production build: node Extensions/e2e/firefox-consent-smoke.js
// RYD_FIREFOX_BINARY selects a portable browser. RYD_FIREFOX_PACKAGED=1 requires
// Developer Edition or Nightly and enables unsigned packages only in the owned profile.
// RYD_FIREFOX_DATA_CONSENT_DISABLED=1 verifies fail-closed behavior when the native consent API is disabled.
// RYD_FIREFOX_CHANGELOG_LIFECYCLE=1 observes temporary install/reload events.
// RYD_FIREFOX_CHANGELOG_EXPECT_IMMEDIATE=1 also asserts immediate reload display and pending-state recovery.
const ROOT = path.resolve(__dirname, "../..");
@@ -114,8 +115,13 @@ async function run() {
);
const firefox = process.env.RYD_FIREFOX_BINARY || "C:\\Program Files\\Mozilla Firefox\\firefox.exe";
const packaged = process.env.RYD_FIREFOX_PACKAGED === "1";
const consentDisabled = process.env.RYD_FIREFOX_DATA_CONSENT_DISABLED === "1";
const changelogLifecycle = process.env.RYD_FIREFOX_CHANGELOG_LIFECYCLE === "1";
assert(!(packaged && changelogLifecycle), "Changelog lifecycle reproduction requires a temporary addon");
assert(
!(consentDisabled && (packaged || changelogLifecycle)),
"Disabled-consent validation requires an ordinary temporary addon",
);
const evidence = path.join(ROOT, "test-results", `firefox-consent-${new Date().toISOString().replace(/[:.]/g, "-")}`);
const profile = path.join(evidence, "profile");
const derived = path.join(evidence, "extension");
@@ -129,6 +135,8 @@ async function run() {
artifact,
version: manifest.version,
scenarios: [],
permissionStates: [],
consentDisabled,
screenshots: [],
requests,
blocked,
@@ -140,7 +148,9 @@ async function run() {
"Temporary installation grants required categories without showing the install/update consent prompt.",
"Native arrow-panel text is captured from Firefox UI; headless screenshots do not capture the separate native panel.",
];
const server = http.createServer((request, response) => {
let githubChallenge;
let githubRedirectUri;
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host}`);
requests.push({ method: request.method, path: url.pathname });
response.setHeader("Access-Control-Allow-Origin", "*");
@@ -153,7 +163,23 @@ async function run() {
request.method === "POST" ? "true" : JSON.stringify({ challenge: "AAAAAAAAAAAAAAAAAAAAAA==", difficulty: 0 }),
);
else if (url.pathname === "/puzzle/registration/confirm") response.end("true");
else if (url.pathname.endsWith("/login")) {
else if (url.pathname === "/api/auth/github/login") {
githubChallenge = url.searchParams.get("codeChallenge");
githubRedirectUri = url.searchParams.get("redirectUri");
const params = new URLSearchParams({
state: "fixture-state",
redirect_uri: githubRedirectUri,
code_challenge: githubChallenge,
code_challenge_method: "S256",
});
response.end(
JSON.stringify({
authUrl: `https://github.com/login/oauth/authorize?${params}`,
state: "fixture-state",
redirectUri: githubRedirectUri,
}),
);
} else if (url.pathname.endsWith("/login")) {
response.end(
JSON.stringify({
authUrl: `${origin}/oauth-authorize?redirectUri=${encodeURIComponent(url.searchParams.get("redirectUri"))}`,
@@ -166,6 +192,22 @@ async function run() {
});
response.end();
} else if (url.pathname.endsWith("/exchange")) {
if (url.pathname === "/api/auth/github/exchange") {
let body = "";
for await (const chunk of request) body += chunk;
const data = JSON.parse(body);
result.githubPkceVerified =
typeof data.codeVerifier === "string" &&
crypto.createHash("sha256").update(data.codeVerifier).digest("base64url") === githubChallenge &&
data.state === "fixture-state" &&
data.code === "fixture-code" &&
data.redirectUri === githubRedirectUri;
if (!result.githubPkceVerified) {
response.writeHead(400);
response.end(JSON.stringify({ error: "github_invalid_state" }));
return;
}
}
response.end(
JSON.stringify({
success: true,
@@ -207,9 +249,33 @@ async function run() {
source.includes("https://returnyoutubedislikeapi.com"),
`${filename} must contain its production API origin`,
);
await fs.writeFile(file, source.replaceAll("https://returnyoutubedislikeapi.com", origin));
let testSource = source.replaceAll("https://returnyoutubedislikeapi.com", origin);
if (filename === "ryd.background.js") {
// Keep the generated OAuth URL/PKCE validation and native identity flow.
// Route only the external provider navigation to the owned loopback fixture.
const providerBoundary = `(() => {
const nativeLaunch = browser.identity.launchWebAuthFlow.bind(browser.identity);
browser.identity.launchWebAuthFlow = (details) => {
const url = new URL(details.url);
if (url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize") {
const fixture = new URL(${JSON.stringify(origin)} + "/oauth-authorize");
fixture.searchParams.set("redirectUri", url.searchParams.get("redirect_uri"));
fixture.searchParams.set("state", url.searchParams.get("state"));
return nativeLaunch({...details, url:fixture.href});
}
return nativeLaunch(details);
};
})();\n`;
testSource = providerBoundary + testSource;
}
await fs.writeFile(file, testSource);
}
result.sourceHashes = sourceHashes;
result.derivation = [
"Replace the production API origin with the owned loopback origin in three generated bundles.",
"Add the loopback host permission only to the owned test copy.",
"Route native identity navigation for the validated GitHub authorize URL to a loopback provider; retain generated URL/state/PKCE checks and the native identity flow.",
];
if (changelogLifecycle)
await require("./firefox-changelog-lifecycle").instrumentChangelogLifecycle({ derived, manifest, result });
manifest.permissions.push(`${origin}/*`);
@@ -241,6 +307,7 @@ async function run() {
"remote.active-protocols": 1,
};
if (packaged) prefs["xpinstall.signatures.required"] = false;
if (consentDisabled) prefs["extensions.dataCollectionPermissions.enabled"] = false;
await fs.writeFile(
path.join(profile, "user.js"),
Object.entries(prefs)
@@ -323,6 +390,40 @@ async function run() {
[],
true,
);
async function assertPermissionState(label, expected) {
const state = await driver.script(
`const done = arguments[arguments.length - 1]; Promise.all([
browser.permissions.getAll(),
browser.permissions.contains({data_collection:["authenticationInfo"]})
]).then(([all, contains]) => done({all, contains, events:window.__rydPermissionEvents || []}));`,
[],
true,
);
result.permissionStates.push({ label, ...state });
assert.equal(state.all.data_collection?.includes("authenticationInfo") === true, expected, label);
return state;
}
async function assertNoAuthenticationTraffic(label) {
const baseline = requests.filter(
(request) => request.path.startsWith("/api/auth/") || request.path === "/oauth-authorize",
).length;
for (const message of ["patreon_oauth_login", "github_oauth_login"]) {
const response = await driver.script(
`const done = arguments[arguments.length - 1]; browser.runtime.sendMessage({message:arguments[0]}).then(done);`,
[message],
true,
);
assert.equal(response.success, false, label);
assert.match(response.error, /consent removed|consent required/, label);
}
await delay(1000);
assert.equal(
requests.filter((request) => request.path.startsWith("/api/auth/") || request.path === "/oauth-authorize")
.length,
baseline,
label,
);
}
if (!packaged) {
result.freshInstallChangelog.storage = await until(
() =>
@@ -337,7 +438,9 @@ async function run() {
}
const optional = manifest.browser_specific_settings.gecko.data_collection_permissions.optional;
assert.deepEqual(optional, ["authenticationInfo"]);
assert(optional.every((permission) => !result.initialPermissions.data_collection.includes(permission)));
assert(optional.every((permission) => !result.initialPermissions.data_collection?.includes(permission)));
await assertPermissionState("fresh installation has no authentication consent", false);
if (consentDisabled) assert.equal(result.initialPermissions.data_collection, undefined);
for (const message of ["patreon_oauth_login", "github_oauth_login"]) {
const deniedMessage = await driver.script(
`const done=arguments[arguments.length-1]; browser.runtime.sendMessage({message:arguments[0]}).then(done);`,
@@ -361,6 +464,11 @@ async function run() {
);
assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0);
result.scenarios.push("cached account cannot verify or log in without consent");
await driver.script(
`window.__rydPermissionEvents = [];
browser.permissions.onAdded.addListener(value => window.__rydPermissionEvents.push({type:"added", ...value}));
browser.permissions.onRemoved.addListener(value => window.__rydPermissionEvents.push({type:"removed", ...value}));`,
);
await delay(1000);
await driver.script(
`const done=arguments[arguments.length-1]; browser.tabs.getCurrent().then(tab=>browser.tabs.update(tab.id,{active:true})).then(()=>done(true));`,
@@ -392,140 +500,185 @@ async function run() {
const shot = await driver.command("WebDriver:TakeScreenshot", { full: true });
await fs.writeFile(path.join(evidence, "popup.png"), Buffer.from(shot.value, "base64"));
result.screenshots.push("popup.png");
await driver.click("#patreon-login-btn");
await driver.context("chrome");
result.nativePermissionPrompt = await until(
() =>
driver.script(
`const notification = document.getElementById("addon-webext-permissions-notification"); return document.getElementById("notification-popup")?.state === "open" && notification ? { heading:notification.getAttribute("endlabel"), data:document.getElementById("addon-webext-perm-list-data-collection").textContent, allow:notification.getAttribute("buttonlabel"), deny:notification.getAttribute("secondarybuttonlabel") } : false;`,
),
"native consent prompt",
);
console.log("NATIVE PROMPT", JSON.stringify(result.nativePermissionPrompt));
assert.match(result.nativePermissionPrompt.data, /authentication information/i);
assert.doesNotMatch(result.nativePermissionPrompt.data, /financial|payment/i);
// Native arrow panels live outside the headless compositor screenshot. Preserve their exact text instead.
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-secondary-button").click();`,
);
await driver.context("content");
await until(async () => {
try {
await driver.command("WebDriver:GetAlertText");
return true;
} catch (_) {
return false;
if (consentDisabled) {
for (const selector of ["#patreon-login-btn", "#github-login-btn"]) {
await driver.click(selector);
await until(async () => {
try {
await driver.command("WebDriver:GetAlertText");
return true;
} catch (_) {
return false;
}
}, "missing-consent notice with native data consent disabled");
await driver.command("WebDriver:DismissAlert");
await assertPermissionState(selector + " stays denied without native data consent", false);
}
}, "extension denial notice");
await driver.command("WebDriver:DismissAlert");
const denied = await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`,
[optional],
true,
);
assert.equal(denied, false);
assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0);
result.scenarios.push("native Deny prevents OAuth requests");
console.log("DENIAL PASSED");
await driver.click("#patreon-login-btn");
await driver.context("chrome");
await until(
() => driver.script(`return document.getElementById("notification-popup")?.state === "open";`),
"second consent prompt",
);
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`,
);
await driver.context("content");
await until(
() => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`),
"mock OAuth completion",
);
const granted = await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`,
[optional],
true,
);
assert.equal(granted, true);
assert.equal(requests.filter((request) => request.path === "/api/auth/oauth/login").length, 1);
assert.equal(
requests.filter((request) => request.path === "/api/auth/oauth/exchange" && request.method === "POST").length,
1,
);
result.scenarios.push("native Allow completes generated Patreon OAuth against loopback");
assert.equal(
await driver.script(`return document.getElementById("patreon-privacy-note").checkVisibility();`),
false,
);
console.log("GRANT PASSED");
const grantShot = await driver.command("WebDriver:TakeScreenshot", { full: true });
await fs.writeFile(path.join(evidence, "popup-granted.png"), Buffer.from(grantShot.value, "base64"));
result.screenshots.push("popup-granted.png");
await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:arguments[0]}).then(done);`,
[optional],
true,
);
await until(
() =>
driver.script(
`const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`,
[],
true,
await assertNoAuthenticationTraffic("disabled native consent cannot authorize either provider");
assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0);
result.scenarios.push(
"disabled native data-consent API rejects cached accounts and both login gestures without authentication traffic",
);
result.validationDepth =
"generated Firefox artifact with loopback API substitution and native data-consent API disabled";
} else {
await driver.click("#patreon-login-btn");
await driver.context("chrome");
result.nativePermissionPrompt = await until(
() =>
driver.script(
`const notification = document.getElementById("addon-webext-permissions-notification"); return document.getElementById("notification-popup")?.state === "open" && notification ? { heading:notification.getAttribute("endlabel"), data:document.getElementById("addon-webext-perm-list-data-collection").textContent, allow:notification.getAttribute("buttonlabel"), deny:notification.getAttribute("secondarybuttonlabel") } : false;`,
),
"native consent prompt",
);
console.log("NATIVE PROMPT", JSON.stringify(result.nativePermissionPrompt));
assert.match(result.nativePermissionPrompt.data, /authentication information/i);
assert.doesNotMatch(result.nativePermissionPrompt.data, /financial|payment/i);
// Native arrow panels live outside the headless compositor screenshot. Preserve their exact text instead.
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-secondary-button").click();`,
);
await driver.context("content");
await until(async () => {
try {
await driver.command("WebDriver:GetAlertText");
return true;
} catch (_) {
return false;
}
}, "extension denial notice");
await driver.command("WebDriver:DismissAlert");
const denied = await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`,
[optional],
true,
);
assert.equal(denied, false);
await assertPermissionState("native Deny leaves authentication consent absent", false);
assert.equal(requests.filter((request) => request.path.startsWith("/api/auth/")).length, 0);
result.scenarios.push("native Deny prevents OAuth requests");
console.log("DENIAL PASSED");
await driver.click("#patreon-login-btn");
await driver.context("chrome");
await until(
() => driver.script(`return document.getElementById("notification-popup")?.state === "open";`),
"second consent prompt",
);
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`,
);
await driver.context("content");
await until(
() => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`),
"mock OAuth completion",
);
const granted = await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.contains({data_collection:arguments[0]}).then(done);`,
[optional],
true,
);
assert.equal(granted, true);
const grantedState = await assertPermissionState("native Allow grants authentication consent", true);
assert(
grantedState.events.some(
(event) => event.type === "added" && event.data_collection?.includes("authenticationInfo"),
),
"stored authentication cleanup after revocation",
);
await until(
() => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`),
"logged-out UI after revocation",
);
result.scenarios.push("native revocation removes stored authentication and logs out popup");
console.log("REVOCATION PASSED");
await driver.click("#github-login-btn");
await driver.context("chrome");
await until(
() => driver.script(`return document.getElementById("notification-popup")?.state === "open";`),
"GitHub consent prompt",
);
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`,
);
await driver.context("content");
await until(
() => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`),
"GitHub mock OAuth completion",
);
assert.equal(
requests.filter((request) => request.path === "/api/auth/github/login" && request.method === "GET").length,
1,
);
assert.equal(
requests.filter((request) => request.path === "/api/auth/github/exchange" && request.method === "POST").length,
1,
);
result.scenarios.push("native Allow completes generated GitHub OAuth against loopback");
await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:["authenticationInfo"]}).then(done);`,
[],
true,
);
await until(
() =>
driver.script(
`const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`,
[],
true,
);
assert.equal(requests.filter((request) => request.path === "/api/auth/oauth/login").length, 1);
assert.equal(
requests.filter((request) => request.path === "/api/auth/oauth/exchange" && request.method === "POST").length,
1,
);
result.scenarios.push("native Allow completes generated Patreon OAuth against loopback");
assert.equal(
await driver.script(`return document.getElementById("patreon-privacy-note").checkVisibility();`),
false,
);
console.log("GRANT PASSED");
const grantShot = await driver.command("WebDriver:TakeScreenshot", { full: true });
await fs.writeFile(path.join(evidence, "popup-granted.png"), Buffer.from(grantShot.value, "base64"));
result.screenshots.push("popup-granted.png");
await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:arguments[0]}).then(done);`,
[optional],
true,
);
await until(
() =>
driver.script(
`const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`,
[],
true,
),
"stored authentication cleanup after revocation",
);
await until(
() => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`),
"logged-out UI after revocation",
);
const revokedState = await assertPermissionState("native revocation removes authentication consent", false);
assert(
revokedState.events.some(
(event) => event.type === "removed" && event.data_collection?.includes("authenticationInfo"),
),
"authentication permission revocation clears GitHub authentication",
);
await until(
() => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`),
"GitHub logged-out UI after authentication permission revocation",
);
result.scenarios.push("revoking authentication category clears GitHub account state");
const revokeShot = await driver.command("WebDriver:TakeScreenshot", { full: true });
await fs.writeFile(path.join(evidence, "popup-revoked.png"), Buffer.from(revokeShot.value, "base64"));
result.screenshots.push("popup-revoked.png");
);
await assertNoAuthenticationTraffic("Patreon revocation blocks subsequent account requests");
result.scenarios.push(
"native revocation removes stored authentication, logs out popup, and blocks subsequent account traffic",
);
console.log("REVOCATION PASSED");
await driver.click("#github-login-btn");
await driver.context("chrome");
await until(
() => driver.script(`return document.getElementById("notification-popup")?.state === "open";`),
"GitHub consent prompt",
);
await driver.script(
`document.querySelector("#addon-webext-permissions-notification .popup-notification-primary-button").click();`,
);
await driver.context("content");
await until(
() => driver.script(`return document.getElementById("patreon-logged-in").style.display === "block";`),
"GitHub mock OAuth completion",
);
assert.equal(
requests.filter((request) => request.path === "/api/auth/github/login" && request.method === "GET").length,
1,
);
assert.equal(
requests.filter((request) => request.path === "/api/auth/github/exchange" && request.method === "POST").length,
1,
);
assert.equal(result.githubPkceVerified, true);
await assertPermissionState("GitHub native Allow grants authentication consent", true);
result.scenarios.push("native Allow completes generated GitHub OAuth against loopback");
await driver.script(
`const done=arguments[arguments.length-1]; browser.permissions.remove({data_collection:["authenticationInfo"]}).then(done);`,
[],
true,
);
await until(
() =>
driver.script(
`const done=arguments[arguments.length-1]; browser.storage.sync.get(["patreonAuthenticated","patreonUser","patreonSessionToken"]).then(value=>done(Object.keys(value).length===0));`,
[],
true,
),
"authentication permission revocation clears GitHub authentication",
);
await until(
() => driver.script(`return document.getElementById("patreon-logged-out").style.display === "block";`),
"GitHub logged-out UI after authentication permission revocation",
);
await assertPermissionState("GitHub revocation removes authentication consent", false);
await assertNoAuthenticationTraffic("GitHub revocation blocks subsequent account requests");
result.scenarios.push(
"revoking authentication category clears GitHub account state and blocks subsequent account traffic",
);
const revokeShot = await driver.command("WebDriver:TakeScreenshot", { full: true });
await fs.writeFile(path.join(evidence, "popup-revoked.png"), Buffer.from(revokeShot.value, "base64"));
result.screenshots.push("popup-revoked.png");
}
if (changelogLifecycle)
await require("./firefox-changelog-lifecycle").observeChangelogLifecycle({
driver,
@@ -551,6 +704,10 @@ async function run() {
!blocked.some((request) => request.host.includes("returnyoutubedislikeapi.com")),
"Extension traffic escaped loopback substitution",
);
assert(
!blocked.some((request) => /(^|\.)(github\.com|patreon\.com)(:|$)/i.test(request.host)),
"Authentication traffic escaped loopback substitution",
);
assert.deepEqual(unexpectedRequests, [], "Unexpected extension request");
result.passed = true;
} catch (error) {
+1 -1
View File
@@ -182,7 +182,7 @@ async function validatePackagedConsent({ driver, derived, evidence, manifest, re
"accepted packaged update",
);
await dismissFinishedNotice(driver);
result.scenarios.push("native required-consent update acceptance activates 4.0.5");
result.scenarios.push(`native required-consent update acceptance activates ${manifest.version}`);
console.log("REQUIRED UPDATE ACCEPTANCE PASSED");
result.validationDepth =
"packaged Firefox Developer artifact with loopback API substitution, native required install/update and optional consent";
+4 -5
View File
@@ -465,12 +465,11 @@ function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) {
false,
"ryd.background.js still contains the production API origin after transformation.",
);
const changelogListener = `api.runtime.onInstalled.addListener((details) => {
maybeShowChangelog(details);
});`;
if (!transformedBackground.includes(changelogListener)) {
const changelogListener =
/api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n maybeShowChangelog\(details\);\r?\n\}\);/g;
if ((transformedBackground.match(changelogListener) ?? []).length !== 1) {
removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-");
throw new Error("ryd.background.js has no recognized first-install changelog listener to suppress.");
throw new Error("ryd.background.js must have exactly one recognized first-install changelog listener to suppress.");
}
const hermeticBackground = transformedBackground.replace(
changelogListener,
+52 -27
View File
@@ -37,7 +37,7 @@ const {
const PRODUCTION_API_ORIGIN = "https://returnyoutubedislikeapi.com";
const temporaryDirectories = [];
function createExtensionFixture() {
function createExtensionFixture(lineEnding = "\n") {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-"));
temporaryDirectories.push(directory);
fs.writeFileSync(
@@ -54,7 +54,10 @@ function createExtensionFixture() {
);
fs.writeFileSync(
path.join(directory, "ryd.background.js"),
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`,
`fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`.replaceAll(
"\n",
lineEnding,
),
);
fs.writeFileSync(path.join(directory, "ryd.content-script.js"), `fetch("${PRODUCTION_API_ORIGIN}/votes")`);
fs.writeFileSync(path.join(directory, "content-style.css"), "#ryd-bar { display: block; }");
@@ -351,32 +354,54 @@ test("the routed fake backend records a valid preflight and blocks an unknown on
]);
});
test("redirects eager MV3 background traffic while leaving routed content-script traffic intact", () => {
const sourceDirectory = createExtensionFixture();
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
test.each(["\n", "\r\n"])(
"redirects MV3 background traffic with %j line endings and preserves routed traffic",
(lineEnding) => {
const sourceDirectory = createExtensionFixture(lineEnding);
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
expect(prepared.replacements).toEqual({ "ryd.background.js": 1, firstInstallChangelogListener: 1 });
expect(prepared.routedBundles).toEqual(["ryd.content-script.js"]);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"http://127.0.0.1:43127/register",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"__rydArtifactWorkerSignals",
);
expect(prepared.workerSignalEndpoint).toBe(`http://127.0.0.1:43127${WORKER_SIGNAL_PATH}`);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"api.runtime.onInstalled.addListener(() => {});",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.content-script.js"), "utf8")).toContain(
`${PRODUCTION_API_ORIGIN}/votes`,
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "menu-fixer.js"), "utf8")).toContain("menuFixerLoaded");
expect(JSON.parse(fs.readFileSync(path.join(prepared.extensionDirectory, "manifest.json"), "utf8"))).toMatchObject({
host_permissions: expect.arrayContaining(["http://127.0.0.1/*"]),
manifest_version: 3,
});
expect(fs.readFileSync(path.join(sourceDirectory, "ryd.background.js"), "utf8")).toContain(PRODUCTION_API_ORIGIN);
expect(prepared.replacements).toEqual({ "ryd.background.js": 1, firstInstallChangelogListener: 1 });
expect(prepared.routedBundles).toEqual(["ryd.content-script.js"]);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"http://127.0.0.1:43127/register",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"__rydArtifactWorkerSignals",
);
expect(prepared.workerSignalEndpoint).toBe(`http://127.0.0.1:43127${WORKER_SIGNAL_PATH}`);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.background.js"), "utf8")).toContain(
"api.runtime.onInstalled.addListener(() => {});",
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "ryd.content-script.js"), "utf8")).toContain(
`${PRODUCTION_API_ORIGIN}/votes`,
);
expect(fs.readFileSync(path.join(prepared.extensionDirectory, "menu-fixer.js"), "utf8")).toContain(
"menuFixerLoaded",
);
expect(JSON.parse(fs.readFileSync(path.join(prepared.extensionDirectory, "manifest.json"), "utf8"))).toMatchObject({
host_permissions: expect.arrayContaining(["http://127.0.0.1/*"]),
manifest_version: 3,
});
expect(fs.readFileSync(path.join(sourceDirectory, "ryd.background.js"), "utf8")).toContain(PRODUCTION_API_ORIGIN);
},
);
test.each([
[
"an unexpected listener body",
(source) => source.replace("maybeShowChangelog(details);", "showOtherPage(details);"),
],
["duplicate changelog listeners", (source) => `${source}\n${source}`],
])("rejects %s instead of suppressing an unrecognized listener", (_label, mutateBackground) => {
const sourceDirectory = createExtensionFixture();
const backgroundPath = path.join(sourceDirectory, "ryd.background.js");
fs.writeFileSync(backgroundPath, mutateBackground(fs.readFileSync(backgroundPath, "utf8")));
expect(() => {
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
}).toThrow(/exactly one recognized first-install changelog listener/);
});
test("rejects an extension artifact whose injected auxiliary script was dropped by the build", () => {
+2 -2
View File
@@ -4,7 +4,7 @@ This source package is provided for Mozilla Add-ons review. The extension is bui
## Build environment
The 4.0.5 release was built with this exact toolchain:
Build the 4.0.6 release with this exact toolchain:
- Windows 11 (PowerShell); the build script also supports Linux and macOS
- Node.js 22.17.0
@@ -49,7 +49,7 @@ The script performs all required technical build steps:
1. Confirms Node.js 22.17.0 and npm 10.8.2 are in use.
2. Installs the locked dependencies with `npm ci`.
3. Builds the extension with the release-specific `npm run build-for-amo` hook.
4. Verifies the generated Firefox manifest is version 4.0.5, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version.
4. Verifies the generated Firefox manifest is version 4.0.6, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version.
5. Verifies `menu-fixer.js` and the other required Firefox files were generated.
6. Verifies `ryd.content-script.js` is below 5 MB.
7. Verifies every generated JavaScript file has no source-map reference.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "return-youtube-dislike",
"version": "4.0.5",
"version": "4.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "return-youtube-dislike",
"version": "4.0.5",
"version": "4.0.6",
"engines": {
"node": "22.17.0",
"npm": "10.8.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "return-youtube-dislike",
"version": "4.0.5",
"version": "4.0.6",
"description": "Chrome extension to return youtube dislikes",
"main": "ryd.content-script.js",
"packageManager": "npm@10.8.2",
+1 -1
View File
@@ -12,7 +12,7 @@ const menuFixerPath = join(firefoxOutputDirectory, "menu-fixer.js");
const maxContentScriptBytes = 5 * 1024 * 1024;
const expectedNodeVersion = "v22.17.0";
const expectedNpmVersion = "10.8.2";
const expectedExtensionVersion = "4.0.5";
const expectedExtensionVersion = "4.0.6";
const expectedFirefoxExtensionId = "{762f9885-5a13-4abd-9c77-433dcd38b8fd}";
const expectedRequiredDataCollectionPermissions = [
"personallyIdentifyingInfo",
+2
View File
@@ -13,6 +13,7 @@ const EXPECTED_NODE = "v22.17.0";
const EXPECTED_NPM = "10.8.2";
const ARTIFACT_VERIFIER_PATH = "Extensions/e2e/verify-extension-artifact.js";
const EXTRA_SOURCE_FILES = Object.freeze([
"AMO_RELEASE_NOTES.md",
"AMO_SUBMISSION_NOTES.md",
"Docs/Privacy Policy",
ARTIFACT_VERIFIER_PATH,
@@ -300,6 +301,7 @@ async function packageRelease(outputDirectory) {
);
verifyBuildReceipt();
for (const [source, destination] of [
["AMO_RELEASE_NOTES.md", "AMO_RELEASE_NOTES.md"],
["AMO_SUBMISSION_NOTES.md", "AMO_SUBMISSION_NOTES.md"],
["README_AMO_SOURCE.md", "README_AMO_SOURCE.md"],
["Docs/Privacy Policy", "PRIVACY_POLICY.md"],
+1
View File
@@ -95,6 +95,7 @@ test("source allowlist includes the standalone build and packaging tools but exc
expect(files).toEqual(
expect.arrayContaining([
"package-lock.json",
"AMO_RELEASE_NOTES.md",
"README_AMO_SOURCE.md",
"scripts/build-firefox-source.mjs",
"scripts/package-amo-release.cjs",