mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-09-12 10:22:10 +02:00
Fix Firefox account consent for 4.0.6
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user