Compare commits

...

14 Commits

Author SHA1 Message Date
mr. m
f4d9821fd7 Revert "gh-15144: Update windows registry on update" (gh-15153) 2026-08-29 01:35:20 +02:00
mr. m
221b909190 gh-15144: Update windows registry on update (gh-15145) 2026-08-28 11:56:05 +02:00
Nikola Hristov
ce0cbde336 gh-15134: Fixes tab removed from group getting focused (gh-15135)
Closes https://github.com/zen-browser/desktop/issues/15134

Fix video:

https://github.com/user-attachments/assets/1c06ea7f-d482-4d4a-8bbe-df41daa27616
2026-08-28 11:56:05 +02:00
mr. m
70ca9ad3bc gh-15119: Update windows registry with old certificates (gh-15120) 2026-08-26 17:51:26 +02:00
mr. m
81aece863f gh-14710: Fixed minimizing PiP window failing to collapse into the sidebar (gh-15111) 2026-08-26 08:31:00 +02:00
CocoLng
e0bb36ef26 gh-15039: Fix New Blank Window menu entry not in Title Case (gh-15056) 2026-08-26 00:11:12 +02:00
Henrik Nyh
1b299d0df4 gh-15039: Use Title Case for split view labels (gh-15059) 2026-08-26 00:11:11 +02:00
mr. m
928699e61c gh-15029: Fixed urlbar suggestion context not working (gh-15037) 2026-08-26 00:11:11 +02:00
mr. m
1f2fb08b09 gh-15031: Fixed duplicate separator on tab context menu (gh-15035) 2026-08-26 00:11:11 +02:00
mr. m
5404c990eb gh-15101: Sync upstream Firefox to version 154.0.1 (gh-15100) 2026-08-26 00:11:11 +02:00
mr. m
47bc3dc294 gh-14990: Prevent policyContainer from bloating the session file (gh-15098) 2026-08-26 00:11:11 +02:00
mr. m
eac29c4b1e gh-15085: Import mochitests for remote settings (gh-15086) 2026-08-26 00:11:10 +02:00
mr. m
cee4147767 gh-14959: Fix wrong wine download resource (gh-15018) 2026-08-19 00:19:10 +02:00
mr. m
6517dd8287 Revert "gh-14470: Add space and container sync (gh-14471)"
This reverts commit 647e219639.
2026-08-18 23:52:24 +02:00
571 changed files with 81905 additions and 1115 deletions

2
.gitattributes vendored
View File

@@ -5,4 +5,4 @@
*.patch linguist-language=C++ *.patch linguist-language=C++
*.d.ts linguist-language=TypeScript *.d.ts linguist-language=TypeScript
src/zen/tests/*.js linguist-language=Test src/zen/tests/** linguist-language=C++

View File

@@ -105,6 +105,8 @@ jobs:
SURFER_COMPAT: ${{ matrix.arch }} SURFER_COMPAT: ${{ matrix.arch }}
SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }} SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }}
SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }} SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }}
SURFER_CERT_PATCH_NAME_PREV: ${{ secrets.SURFER_CERT_PATCH_NAME_PREV }}
SURFER_CERT_PATCH_ISSUER_PREV: ${{ secrets.SURFER_CERT_PATCH_ISSUER_PREV }}
run: | run: |
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
npm run import npm run import

View File

@@ -122,6 +122,8 @@ jobs:
SURFER_COMPAT: ${{ matrix.arch }} SURFER_COMPAT: ${{ matrix.arch }}
SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }} SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }}
SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }} SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }}
SURFER_CERT_PATCH_NAME_PREV: ${{ secrets.SURFER_CERT_PATCH_NAME_PREV }}
SURFER_CERT_PATCH_ISSUER_PREV: ${{ secrets.SURFER_CERT_PATCH_ISSUER_PREV }}
SURFER_PLATFORM: darwin SURFER_PLATFORM: darwin
run: | run: |
. "$HOME/.cargo/env" . "$HOME/.cargo/env"

View File

@@ -154,7 +154,7 @@ jobs:
zlib1g-dev \ zlib1g-dev \
aria2 aria2
echo Setup wine echo Setup wine
aria2c "https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/dQz_aHy8Rl-Lt0xf2WlrMw/artifacts/public/build/wine.tar.zst" -o wine.tar.zst aria2c "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/gecko.cache.level-3.toolchains.v3.linux64-wine.latest/artifacts/public/build/wine.tar.zst" -o wine.tar.zst
tar --zstd -xf wine.tar.zst -C ~/win-cross tar --zstd -xf wine.tar.zst -C ~/win-cross
rm wine.tar.zst rm wine.tar.zst
echo Setup Visual Studio echo Setup Visual Studio
@@ -166,6 +166,8 @@ jobs:
SURFER_COMPAT: ${{ matrix.arch }} SURFER_COMPAT: ${{ matrix.arch }}
SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }} SURFER_CERT_PATCH_ISSUER: ${{ secrets.SURFER_CERT_PATCH_ISSUER }}
SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }} SURFER_CERT_PATCH_NAME: ${{ secrets.SURFER_CERT_PATCH_NAME }}
SURFER_CERT_PATCH_NAME_PREV: ${{ secrets.SURFER_CERT_PATCH_NAME_PREV }}
SURFER_CERT_PATCH_ISSUER_PREV: ${{ secrets.SURFER_CERT_PATCH_ISSUER_PREV }}
run: | run: |
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
npm run import -- --verbose npm run import -- --verbose

View File

@@ -34,8 +34,8 @@ Zen is a firefox-based browser with the aim of pushing your productivity to a ne
### Firefox Versions ### Firefox Versions
- [`Release`](https://zen-browser.app/download) - Is currently built using Firefox version `154.0`! 🚀 - [`Release`](https://zen-browser.app/download) - Is currently built using Firefox version `154.0.1`!
- [`Twilight`](https://zen-browser.app/download?twilight) - Is currently built using Firefox version `RC 154.0`! - [`Twilight`](https://zen-browser.app/download?twilight) - Is currently built using Firefox version `RC 154.0.1`!
### Contributing ### Contributing

View File

@@ -1 +1 @@
1fb0703fa9723fd9c400ad60d18e64ee4f9bf125 bf4b57fd7fe94054457be7018e8e7beaf754c520

View File

@@ -3,8 +3,6 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
param( param(
[string][Parameter(Mandatory=$true)]$SignIdentity,
[string][Parameter(Mandatory=$true)]$SignIdentityIssuer,
[string][Parameter(Mandatory=$true)]$GithubRunId [string][Parameter(Mandatory=$true)]$GithubRunId
) )
@@ -24,12 +22,23 @@ mkdir windsign-temp -ErrorAction SilentlyContinue
# echo "Downloaded git objects repo to" # echo "Downloaded git objects repo to"
#} -Verbose -ArgumentList $PWD -Debug #} -Verbose -ArgumentList $PWD -Debug
$token = gh auth token
$env:SURFER_MOZCONFIG_ONLY="1" $env:SURFER_MOZCONFIG_ONLY="1"
$env:SURFER_SIGNING_MODE="" $env:SURFER_SIGNING_MODE=""
# Fetch SignIdentity from the github repo secrets, with the name SURFER_CERT_PATCH_ISSUER
$SignIdentityIssuer = (Invoke-RestMethod -Uri "https://api.github.com/repos/zen-browser/desktop/actions/secrets/SURFER_CERT_PATCH_ISSUER" -Headers @{Authorization = "Bearer $token"}).value
$SignIdentity = (Invoke-RestMethod -Uri "https://api.github.com/repos/zen-browser/desktop/actions/secrets/SURFER_CERT_PATCH_NAME" -Headers @{Authorization = "Bearer $token"}).value
$SignIndentityIssuerPrev = (Invoke-RestMethod -Uri "https://api.github.com/repos/zen-browser/desktop/actions/secrets/SURFER_CERT_PATCH_ISSUER_PREV" -Headers @{Authorization = "Bearer $token"}).value
$SignIdentityPrev = (Invoke-RestMethod -Uri "https://api.github.com/repos/zen-browser/desktop/actions/secrets/SURFER_CERT_PATCH_NAME_PREV" -Headers @{Authorization = "Bearer $token"}).
$env:SURFER_CERT_PATCH_ISSUER=$SignIdentityIssuer $env:SURFER_CERT_PATCH_ISSUER=$SignIdentityIssuer
$env:SURFER_CERT_PATCH_NAME=$SignIdentity $env:SURFER_CERT_PATCH_NAME=$SignIdentity
$env:SURFER_CERT_PATCH_NAME_PREV=$SignIdentityPrev
$env:SURFER_CERT_PATCH_ISSUER_PREV=$SignIndentityIssuerPrev
Start-Job -Name "DownloadGitl10n" -ScriptBlock { Start-Job -Name "DownloadGitl10n" -ScriptBlock {
param($PWD) param($PWD)
cd $PWD cd $PWD
@@ -46,7 +55,6 @@ npm run build
echo "Downloading artifacts info" echo "Downloading artifacts info"
$artifactsInfo=gh api repos/zen-browser/desktop/actions/runs/$GithubRunId/artifacts $artifactsInfo=gh api repos/zen-browser/desktop/actions/runs/$GithubRunId/artifacts
$token = gh auth token
function New-TemporaryDirectory { function New-TemporaryDirectory {
$tmp = [System.IO.Path]::GetTempPath() # Not $env:TEMP, see https://stackoverflow.com/a/946017 $tmp = [System.IO.Path]::GetTempPath() # Not $env:TEMP, see https://stackoverflow.com/a/946017
@@ -215,20 +223,12 @@ SignAndPackage x86_64
$files = Get-ChildItem .\windsign-temp\windows-x64-signed-x86_64, .\windsign-temp\windows-x64-signed-arm64 -Recurse -Include *.exe $files = Get-ChildItem .\windsign-temp\windows-x64-signed-x86_64, .\windsign-temp\windows-x64-signed-arm64 -Recurse -Include *.exe
signtool.exe sign /n "$SignIdentity" /t http://time.certum.pl/ /fd sha256 /v $files signtool.exe sign /n "$SignIdentity" /t http://time.certum.pl/ /fd sha256 /v $files
# Compress each signed bundle into a single gzip tarball (`-C <dir> .` so it has
# no top-level directory) and upload it as a release asset on the windows-binaries
# repo, keyed to this build's run id. Release assets keep the bundles out of git
# (they were too large to commit) while still living in the windows-binaries repo.
# The gated release job downloads and expands these from that repo's releases.
$binariesRepo = "zen-browser/windows-binaries" $binariesRepo = "zen-browser/windows-binaries"
$stagingTag = "windows-signed-$GithubRunId" $stagingTag = "windows-signed-$GithubRunId"
echo "Ensuring staging release $stagingTag exists on $binariesRepo" echo "Ensuring staging release $stagingTag exists on $binariesRepo"
gh release view $stagingTag --repo $binariesRepo *> $null gh release create $stagingTag --repo $binariesRepo --prerelease --title "Windows signed bundles ($GithubRunId)" --notes "Signed Windows bundles for run $GithubRunId, consumed by the release workflow. Safe to delete."
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
gh release create $stagingTag --repo $binariesRepo --prerelease --title "Windows signed bundles ($GithubRunId)" --notes "Signed Windows bundles for run $GithubRunId, consumed by the release workflow. Safe to delete." throw "Failed to create staging release $stagingTag on $binariesRepo"
if ($LASTEXITCODE -ne 0) {
throw "Failed to create staging release $stagingTag on $binariesRepo"
}
} }
foreach ($name in @("x86_64", "arm64")) { foreach ($name in @("x86_64", "arm64")) {

View File

@@ -164,5 +164,5 @@ zen-window-sync-migration-dialog-learn-more = Learn More
zen-window-sync-migration-dialog-accept = Got It zen-window-sync-migration-dialog-accept = Got It
zen-appmenu-new-blank-window = zen-appmenu-new-blank-window =
.label = New blank window .label = New Blank Window

View File

@@ -5,14 +5,14 @@
tab-zen-split-tabs = tab-zen-split-tabs =
.label = .label =
{ $tabCount -> { $tabCount ->
[-1] Split out tab [-1] Split Out Tab
[1] Add split view... [1] Add Split View...
*[other] Join { $tabCount } Tabs *[other] Join { $tabCount } Tabs
} }
.accesskey = S .accesskey = S
zen-split-link = zen-split-link =
.label = Split link to new tab .label = Split Link to New Tab
.accesskey = S .accesskey = S
zen-split-view-modifier-header = Split View zen-split-view-modifier-header = Split View

8
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"devDependencies": { "devDependencies": {
"@babel/preset-typescript": "^7.27.0", "@babel/preset-typescript": "^7.27.0",
"@zen-browser/prettier": "^3.9.3", "@zen-browser/prettier": "^3.9.3",
"@zen-browser/surfer": "^1.14.7", "@zen-browser/surfer": "^1.14.8",
"formal-git": "^1.2.9", "formal-git": "^1.2.9",
"globals": "^16.3.0", "globals": "^16.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",
@@ -860,9 +860,9 @@
} }
}, },
"node_modules/@zen-browser/surfer": { "node_modules/@zen-browser/surfer": {
"version": "1.14.7", "version": "1.14.8",
"resolved": "https://registry.npmjs.org/@zen-browser/surfer/-/surfer-1.14.7.tgz", "resolved": "https://registry.npmjs.org/@zen-browser/surfer/-/surfer-1.14.8.tgz",
"integrity": "sha512-Ui2Chqvr4/+FmHM54eKOv7cQu8DRAK/uCX9o1GNi81hszxo/RSQwaw71eNW43Qcj7giK4U4x0IdCMht98iQ0rA==", "integrity": "sha512-5HHcfibwHIK5n3huhJL2TLBX2+8odj6ebCMA6U2EcUj9ROSrcz7/bjY2n5MIa9oIRL9gtV3ott0jwJj6SlapxA==",
"dev": true, "dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {

View File

@@ -49,7 +49,7 @@
"devDependencies": { "devDependencies": {
"@babel/preset-typescript": "^7.27.0", "@babel/preset-typescript": "^7.27.0",
"@zen-browser/prettier": "^3.9.3", "@zen-browser/prettier": "^3.9.3",
"@zen-browser/surfer": "^1.14.7", "@zen-browser/surfer": "^1.14.8",
"formal-git": "^1.2.9", "formal-git": "^1.2.9",
"globals": "^16.3.0", "globals": "^16.3.0",
"husky": "^9.1.7", "husky": "^9.1.7",

View File

@@ -1,12 +0,0 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
- name: services.sync.engine.spaces
value: true
condition: "@IS_TWILIGHT@"
- name: services.sync.engine.spaces
value: false
locked: true
condition: "!@IS_TWILIGHT@"

View File

@@ -32,6 +32,9 @@
- name: zen.workspaces.scroll-modifier-key - name: zen.workspaces.scroll-modifier-key
value: ctrl value: ctrl
- name: services.sync.engine.workspaces
value: false
- name: zen.workspaces.separate-essentials - name: zen.workspaces.separate-essentials
value: true value: true

View File

@@ -8,4 +8,4 @@ packaging==24.2
pathspec==0.12.1 pathspec==0.12.1
platformdirs==4.3.6 platformdirs==4.3.6
pycodestyle==2.12.1 pycodestyle==2.12.1
requests==2.33.0 requests==2.34.2

View File

@@ -1,8 +1,8 @@
diff --git a/.prettierignore b/.prettierignore diff --git a/.prettierignore b/.prettierignore
index 949896ff064ae0b54b6a657ea074bc88e12820f7..5249f420972667bece4d85fe8d35073afaebeb8a 100644 index bd8ab46ff7175f5039b69b04c1afe53ab8efe746..7ae12a1ff3724e99aeec2e93518640a2fa9611e6 100644
--- a/.prettierignore --- a/.prettierignore
+++ b/.prettierignore +++ b/.prettierignore
@@ -1803,3 +1803,12 @@ tools/ts/test/baselines/ @@ -1807,3 +1807,12 @@ tools/ts/test/baselines/
try_task_config.json try_task_config.json
xpcom/idl-parser/xpidl/fixtures/xpctest.d.json xpcom/idl-parser/xpidl/fixtures/xpctest.d.json
**/package-lock.json **/package-lock.json
@@ -12,6 +12,6 @@ index 949896ff064ae0b54b6a657ea074bc88e12820f7..5249f420972667bece4d85fe8d35073a
+*.min.js +*.min.js
+*.min.mjs +*.min.mjs
+*.inc +*.inc
+*/mochitests/* +**/mochitests/**
+*.svg +*.svg
+ +

View File

@@ -1,20 +0,0 @@
diff --git a/browser/components/preferences/config/account-sync.mjs b/browser/components/preferences/config/account-sync.mjs
index b503987a08e2ce64bfc01c4e3a21e5e19ef834f0..b1c03a0b219fd3eddd93c1deaeb18ab79c85f168 100644
--- a/browser/components/preferences/config/account-sync.mjs
+++ b/browser/components/preferences/config/account-sync.mjs
@@ -42,6 +42,7 @@ Preferences.addAll([
{ id: "services.sync.engine.creditcards", type: "bool" },
{ id: "services.sync.engine.addons", type: "bool" },
{ id: "services.sync.engine.prefs", type: "bool" },
+ { id: "services.sync.engine.spaces", type: "bool" },
]);
/**
@@ -546,6 +547,7 @@ const SYNC_ENGINE_SETTINGS = [
type: "payments",
},
{ id: "syncAddons", pref: "services.sync.engine.addons", type: "addons" },
+ { id: "syncSpaces", pref: "services.sync.engine.spaces", type: "workspaces" },
{ id: "syncSettings", pref: "services.sync.engine.prefs", type: "settings" },
];

View File

@@ -6,7 +6,7 @@ index 64aa0d98a0622c01f3dcfff1a04bfcda368354d2..2013e04b0881ad2295d6897b91e1573c
{ id: "services.sync.engine.passwords", type: "bool" }, { id: "services.sync.engine.passwords", type: "bool" },
{ id: "services.sync.engine.addresses", type: "bool" }, { id: "services.sync.engine.addresses", type: "bool" },
{ id: "services.sync.engine.creditcards", type: "bool" }, { id: "services.sync.engine.creditcards", type: "bool" },
+ { id: "services.sync.engine.spaces", type: "bool" }, + { id: "services.sync.engine.workspaces", type: "bool" },
]); ]);
let gSyncChooseWhatToSync = { let gSyncChooseWhatToSync = {

View File

@@ -20,7 +20,7 @@ index a893c5ec3d007820d98f5d92dd039640faa2c181..9cbd00102e44ccf98b37845474d92d57
+ <html:div class="sync-engine-workspaces"> + <html:div class="sync-engine-workspaces">
+ <checkbox + <checkbox
+ data-l10n-id="sync-engine-workspaces" + data-l10n-id="sync-engine-workspaces"
+ preference="services.sync.engine.spaces" + preference="services.sync.engine.workspaces"
+ /> + />
+ </html:div> + </html:div>
</html:div> </html:div>

View File

@@ -6,7 +6,7 @@ index c379e1a5f82692406a92d9fcd3bca2769dfac5b2..af037dd3d995813d966524ac44a3795d
<image class="sync-engine-image sync-engine-prefs" alt=""/> <image class="sync-engine-image sync-engine-prefs" alt=""/>
<label data-l10n-id="sync-currently-syncing-settings"/> <label data-l10n-id="sync-currently-syncing-settings"/>
</html:div> </html:div>
+ <html:div engine_preference="services.sync.engine.spaces"> + <html:div engine_preference="services.sync.engine.workspaces">
+ <image class="sync-engine-image sync-engine-workspaces" alt=""/> + <image class="sync-engine-image sync-engine-workspaces" alt=""/>
+ <label data-l10n-id="sync-currently-syncing-workspaces"/> + <label data-l10n-id="sync-currently-syncing-workspaces"/>
+ </html:div> + </html:div>

View File

@@ -1,8 +1,8 @@
diff --git a/browser/components/tabbrowser/content/tab-context-menu.js b/browser/components/tabbrowser/content/tab-context-menu.js diff --git a/browser/components/tabbrowser/content/tab-context-menu.js b/browser/components/tabbrowser/content/tab-context-menu.js
index 4a7cb33a4d9067d469bbe09d4cd8844581642f7a..2e2404b78deb428e57d01b8b929231f675a21323 100644 index 4a7cb33a4d9067d469bbe09d4cd8844581642f7a..d7f75471a207cccac6ca3dec49037ac30dd76163 100644
--- a/browser/components/tabbrowser/content/tab-context-menu.js --- a/browser/components/tabbrowser/content/tab-context-menu.js
+++ b/browser/components/tabbrowser/content/tab-context-menu.js +++ b/browser/components/tabbrowser/content/tab-context-menu.js
@@ -839,7 +839,8 @@ var TabContextMenu = { @@ -839,13 +839,15 @@ var TabContextMenu = {
); );
contextUnpinSelectedTabs.hidden = contextUnpinSelectedTabs.hidden =
!this.contextTab.pinned || !this.multiselected; !this.contextTab.pinned || !this.multiselected;
@@ -12,3 +12,10 @@ index 4a7cb33a4d9067d469bbe09d4cd8844581642f7a..2e2404b78deb428e57d01b8b929231f6
// Build Ask Chat items. The classic layout shows the full ask-chat submenu // Build Ask Chat items. The classic layout shows the full ask-chat submenu
// (#context_askChat); the alt layout shows only a flat "Summarize Page" item // (#context_askChat); the alt layout shows only a flat "Summarize Page" item
// (#context_askChatSummarize). Hide whichever one this layout doesn't use so // (#context_askChatSummarize). Hide whichever one this layout doesn't use so
// a pref toggle reverses cleanly.
if (!this._altTabContextMenu) {
document.getElementById("context_askChatSummarize").hidden = true;
+ document.getElementById("context_aiSeparator").hidden = true;
TabContextMenu.GenAI.buildTabMenu(
document.getElementById("context_askChat"),
this

View File

@@ -1,8 +1,18 @@
diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs
index 2d21248256c6c2bfb8dac958133c10e3251ef564..f788bd10ec2c08e4b27b77cd3bb0489fb04e8b7a 100644 index 89f0d44d462de3d7d0f581d6371606f475443822..0d45ce5f6455e9c7276c54f504906bade5b3d286 100644
--- a/browser/components/urlbar/UrlbarPrefs.sys.mjs --- a/browser/components/urlbar/UrlbarPrefs.sys.mjs
+++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs +++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs
@@ -462,6 +462,7 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([ @@ -163,6 +163,9 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([
// Feature gate pref for flight status suggestions in the urlbar.
["flightStatus.featureGate", false],
+ // Whether to close the urlbar when blurring the window while the urlbar is focused.
+ ["closeOnWindowBlur", true],
+
// The minimum prefix length of a flight status keyword the user must type to
// trigger the suggestion. 0 means the min length should be taken from Nimbus
// or remote settings.
@@ -482,6 +485,7 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([
["shortcuts.tabs", true], ["shortcuts.tabs", true],
["shortcuts.history", true], ["shortcuts.history", true],
["shortcuts.actions", true], ["shortcuts.actions", true],
@@ -10,7 +20,7 @@ index 2d21248256c6c2bfb8dac958133c10e3251ef564..f788bd10ec2c08e4b27b77cd3bb0489f
// Boolean to determine if the providers defined in `exposureResults` // Boolean to determine if the providers defined in `exposureResults`
// should be displayed in search results. This can be set by a // should be displayed in search results. This can be set by a
@@ -799,6 +800,8 @@ function makeDefaultResultGroups({ showSearchSuggestionsFirst }) { @@ -840,6 +844,8 @@ function makeDefaultResultGroups({
*/ */
let rootGroup = { let rootGroup = {
children: [ children: [

View File

@@ -1,5 +1,5 @@
diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs
index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a6182579cbaf4d 100644 index e7e9495e56076cd112beafb8297dece4ae4cea58..95e4333866a5f0d41fbdbe23910c396406a247e9 100644
--- a/browser/components/urlbar/content/UrlbarInput.mjs --- a/browser/components/urlbar/content/UrlbarInput.mjs
+++ b/browser/components/urlbar/content/UrlbarInput.mjs +++ b/browser/components/urlbar/content/UrlbarInput.mjs
@@ -101,6 +101,13 @@ const lazy = XPCOMUtils.declareLazy({ @@ -101,6 +101,13 @@ const lazy = XPCOMUtils.declareLazy({
@@ -280,7 +280,20 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
l10nId, l10nId,
l10nId == "urlbar-placeholder-with-name" l10nId == "urlbar-placeholder-with-name"
? { name: engineName } ? { name: engineName }
@@ -5424,6 +5540,11 @@ ${ @@ -5347,6 +5463,12 @@ ${
}
lazy.logger.debug("Blur Event");
+ if (
+ this.document.commandDispatcher.focusedElement == this.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// We cannot count every blur events after a missed engagement as abandoment
// because the user may have clicked on some view element that executes
// a command causing a focus change. For example opening preferences from
@@ -5424,6 +5546,11 @@ ${
} }
_on_click(event) { _on_click(event) {
@@ -292,7 +305,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
switch (event.target) { switch (event.target) {
case this.inputField: case this.inputField:
case this._inputContainer: case this._inputContainer:
@@ -5513,10 +5634,11 @@ ${ @@ -5513,10 +5640,11 @@ ${
} }
if (untrim) { if (untrim) {
this.setValue(this._untrimmedValue); this.setValue(this._untrimmedValue);
@@ -305,7 +318,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
this.view.autoOpen({ event }); this.view.autoOpen({ event });
} else { } else {
if (this._untrimOnFocusAfterKeydown) { if (this._untrimOnFocusAfterKeydown) {
@@ -5556,9 +5678,16 @@ ${ @@ -5556,9 +5684,16 @@ ${
} }
_on_mousedown(event) { _on_mousedown(event) {
@@ -323,7 +336,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
if ( if (
event.composedTarget != this.inputField && event.composedTarget != this.inputField &&
event.composedTarget != this._inputContainer event.composedTarget != this._inputContainer
@@ -5568,6 +5697,10 @@ ${ @@ -5568,6 +5703,10 @@ ${
this.focusedViaMousedown = !this.focused; this.focusedViaMousedown = !this.focused;
this.#preventClickSelectsAll = this.focused; this.#preventClickSelectsAll = this.focused;
@@ -334,7 +347,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
// Keep the focus status, since the attribute may be changed // Keep the focus status, since the attribute may be changed
// upon calling this.focus(). // upon calling this.focus().
@@ -5605,7 +5738,7 @@ ${ @@ -5605,7 +5744,7 @@ ${
// view open on tab switch, and the TabSelect event arrived earlier. // view open on tab switch, and the TabSelect event arrived earlier.
// Also ignore mousedown on the urlbarView context menu: opening/closing the // Also ignore mousedown on the urlbarView context menu: opening/closing the
// view is already handled by the result opening flow. // view is already handled by the result opening flow.
@@ -343,7 +356,7 @@ index e7e9495e56076cd112beafb8297dece4ae4cea58..330104d245ddd0956b95377082a61825
break; break;
} }
@@ -5894,7 +6027,7 @@ ${ @@ -5894,7 +6033,7 @@ ${
// When we are in actions search mode we can show more results so // When we are in actions search mode we can show more results so
// increase the limit. // increase the limit.
let maxResults = let maxResults =

View File

@@ -1,8 +1,8 @@
diff --git a/browser/components/urlbar/content/UrlbarView.mjs b/browser/components/urlbar/content/UrlbarView.mjs diff --git a/browser/components/urlbar/content/UrlbarView.mjs b/browser/components/urlbar/content/UrlbarView.mjs
index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac823fedcb 100644 index e465806c4ae4fc372d9aa2b71aa7f0e74c65d026..c0b3781901298bedee2736819a93edbf545507a7 100644
--- a/browser/components/urlbar/content/UrlbarView.mjs --- a/browser/components/urlbar/content/UrlbarView.mjs
+++ b/browser/components/urlbar/content/UrlbarView.mjs +++ b/browser/components/urlbar/content/UrlbarView.mjs
@@ -674,7 +674,7 @@ export class UrlbarView { @@ -803,7 +803,7 @@ export class UrlbarView {
!this.input.value || !this.input.value ||
this.input.getAttribute("pageproxystate") == "valid" this.input.getAttribute("pageproxystate") == "valid"
) { ) {
@@ -11,7 +11,7 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
// Try to reuse the cached top-sites context. If it's not cached, then // Try to reuse the cached top-sites context. If it's not cached, then
// there will be a gap of time between when the input is focused and // there will be a gap of time between when the input is focused and
// when the view opens that can be perceived as flicker. // when the view opens that can be perceived as flicker.
@@ -812,10 +812,6 @@ export class UrlbarView { @@ -941,10 +941,6 @@ export class UrlbarView {
} }
// If search mode isn't active, close the view. // If search mode isn't active, close the view.
@@ -22,7 +22,7 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
// Search mode is active. If the one-offs should be shown, make sure they // Search mode is active. If the one-offs should be shown, make sure they
// are enabled and show the view. // are enabled and show the view.
@@ -3031,6 +3027,8 @@ export class UrlbarView { @@ -3203,6 +3199,8 @@ export class UrlbarView {
if (row?.hasAttribute("row-selectable")) { if (row?.hasAttribute("row-selectable")) {
row?.toggleAttribute("selected", true); row?.toggleAttribute("selected", true);
} }
@@ -31,7 +31,7 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
if (element != row) { if (element != row) {
row?.toggleAttribute("descendant-selected", true); row?.toggleAttribute("descendant-selected", true);
} }
@@ -3544,7 +3542,7 @@ export class UrlbarView { @@ -3715,7 +3713,7 @@ export class UrlbarView {
} }
#enableOrDisableRowWrap() { #enableOrDisableRowWrap() {
@@ -40,3 +40,16 @@ index 8afb2bf4a757b3ef7902f5a6e6eed6b70ca2845e..440dea1e66540ca1998cb7464b6695ac
this.#rows.toggleAttribute("wrap", wrap); this.#rows.toggleAttribute("wrap", wrap);
this.oneOffSearchButtons?.container.toggleAttribute("wrap", wrap); this.oneOffSearchButtons?.container.toggleAttribute("wrap", wrap);
} }
@@ -4144,6 +4142,12 @@ export class UrlbarView {
}
on_blur() {
+ if (
+ this.document.commandDispatcher.focusedElement == this.input.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// If the view is open without the input being focused, it will not close
// automatically when the window loses focus. We might be in this state
// after a Search Tip is shown on an engine homepage.

View File

@@ -1,76 +0,0 @@
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -411,10 +411,13 @@
pref("browser.urlbar.maxRichResults", 10);
// The maximum number of historical search results to show.
pref("browser.urlbar.maxHistoricalSearchSuggestions", 2);
+// Whether to close the urlbar when blurring the window while the urlbar is focused.
+pref("browser.urlbar.closeOnWindowBlur", true);
+
// The default behavior for the urlbar can be configured to use any combination
// of the match filters with each additional filter adding more results (union).
pref("browser.urlbar.suggest.bookmark", true);
pref("browser.urlbar.suggest.clipboard", true);
pref("browser.urlbar.suggest.history", true);
diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs
--- a/browser/components/urlbar/UrlbarPrefs.sys.mjs
+++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs
@@ -152,10 +152,13 @@
["filter.javascript", true],
// Feature gate pref for flight status suggestions in the urlbar.
["flightStatus.featureGate", false],
+ // Whether to close the urlbar when blurring the window while the urlbar is focused.
+ ["closeOnWindowBlur", true],
+
// The minimum prefix length of a flight status keyword the user must type to
// trigger the suggestion. 0 means the min length should be taken from Nimbus
// or remote settings.
["flightStatus.minKeywordLength", 0],
diff --git a/browser/components/urlbar/content/UrlbarView.mjs b/browser/components/urlbar/content/UrlbarView.mjs
--- a/browser/components/urlbar/content/UrlbarView.mjs
+++ b/browser/components/urlbar/content/UrlbarView.mjs
@@ -3909,10 +3909,16 @@
}
}
}
on_blur() {
+ if (
+ this.document.commandDispatcher.focusedElement == this.input.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// If the view is open without the input being focused, it will not close
// automatically when the window loses focus. We might be in this state
// after a Search Tip is shown on an engine homepage.
if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) {
this.close();
diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs
--- a/browser/components/urlbar/content/UrlbarInput.mjs
+++ b/browser/components/urlbar/content/UrlbarInput.mjs
@@ -5460,11 +5460,17 @@
_on_blur(event) {
if (this.view.resultMenu.hasAttribute("open")) {
return;
}
lazy.logger.debug("Blur Event");
+ if (
+ this.document.commandDispatcher.focusedElement == this.inputField &&
+ !lazy.UrlbarPrefs.get("closeOnWindowBlur")
+ ) {
+ return;
+ }
// We cannot count every blur events after a missed engagement as abandoment
// because the user may have clicked on some view element that executes
// a command causing a focus change. For example opening preferences from
// the oneoff settings button.
// For now we detect that case by discarding the event on command, but we
// may want to figure out a more robust way to detect abandonment.

View File

@@ -1,167 +0,0 @@
diff --git a/gfx/webrender_bindings/WebRenderAPI.cpp b/gfx/webrender_bindings/WebRenderAPI.cpp
--- a/gfx/webrender_bindings/WebRenderAPI.cpp
+++ b/gfx/webrender_bindings/WebRenderAPI.cpp
@@ -298,11 +298,13 @@
panic_on_gl_error, picTileWidth, picTileHeight,
gfx::gfxVars::WebRenderRequiresHardwareDriver(),
StaticPrefs::gfx_webrender_low_quality_pinch_zoom_AtStartup(),
StaticPrefs::gfx_webrender_max_shared_surface_size_AtStartup(),
StaticPrefs::gfx_webrender_enable_subpixel_aa_AtStartup(),
- compositor->ShouldUseLayerCompositor())) {
+ compositor->ShouldUseLayerCompositor(),
+ StaticPrefs::
+ gfx_webrender_opaque_backdrop_fallback_AtStartup())) {
// wr_window_new puts a message into gfxCriticalNote if it returns
// false
MOZ_ASSERT(errorMessage);
error.AssignASCII(errorMessage);
wr_api_free_error_msg(errorMessage);
diff --git a/gfx/webrender_bindings/src/bindings.rs b/gfx/webrender_bindings/src/bindings.rs
--- a/gfx/webrender_bindings/src/bindings.rs
+++ b/gfx/webrender_bindings/src/bindings.rs
@@ -1998,10 +1998,11 @@
reject_software_rasterizer: bool,
low_quality_pinch_zoom: bool,
max_shared_surface_size: i32,
enable_subpixel_aa: bool,
use_layer_compositor: bool,
+ opaque_backdrop_fallback: bool,
) -> bool {
assert!(unsafe { is_in_render_thread() });
// Ensure the WR profiler callbacks are hooked up to the Gecko profiler.
set_profiler_hooks(Some(&PROFILER_HOOKS));
@@ -2164,10 +2165,11 @@
texture_cache_config,
reject_software_rasterizer,
low_quality_pinch_zoom,
max_shared_surface_size,
enable_dithering,
+ opaque_backdrop_fallback,
..Default::default()
};
let window_size = DeviceIntSize::new(window_width, window_height);
let notifier = Box::new(CppNotifier { window_id });
diff --git a/gfx/wr/webrender/src/device/gl.rs b/gfx/wr/webrender/src/device/gl.rs
--- a/gfx/wr/webrender/src/device/gl.rs
+++ b/gfx/wr/webrender/src/device/gl.rs
@@ -3982,10 +3982,14 @@
pub fn disable_color_write(&self) {
self.gl.color_mask(false, false, false, false);
}
+ pub fn set_color_mask(&self, r: bool, g: bool, b: bool, a: bool) {
+ self.gl.color_mask(r, g, b, a);
+ }
+
pub fn set_blend(&mut self, enable: bool) {
if enable {
self.gl.enable(gl::BLEND);
} else {
self.gl.disable(gl::BLEND);
diff --git a/gfx/wr/webrender/src/renderer/init.rs b/gfx/wr/webrender/src/renderer/init.rs
--- a/gfx/wr/webrender/src/renderer/init.rs
+++ b/gfx/wr/webrender/src/renderer/init.rs
@@ -204,6 +204,8 @@
pub low_quality_pinch_zoom: bool,
pub max_shared_surface_size: i32,
/// If true, open a debug socket to listen for remote debugger.
/// Relies on `debugger` cargo feature being enabled.
pub enable_debugger: bool,
+ /// See explanation of `gfx.webrender.opaque-backdrop-fallback`.
+ pub opaque_backdrop_fallback: bool,
}
@@ -277,9 +279,10 @@
enable_instancing: true,
reject_software_rasterizer: false,
low_quality_pinch_zoom: false,
max_shared_surface_size: 2048,
enable_debugger: true,
+ opaque_backdrop_fallback: false,
}
}
}
@@ -802,10 +805,11 @@
allocated_native_surfaces: FastHashSet::default(),
debug_overlay_state: DebugOverlayState::new(),
buffer_damage_tracker: BufferDamageTracker::default(),
max_primitive_instance_count,
enable_instancing: options.enable_instancing,
+ opaque_backdrop_fallback: options.opaque_backdrop_fallback,
consecutive_oom_frames: 0,
target_frame_publish_id: None,
pending_result_msg: None,
layer_compositor_frame_state_in_prev_frame: None,
external_composite_debug_items: Vec::new(),
diff --git a/gfx/wr/webrender/src/renderer/mod.rs b/gfx/wr/webrender/src/renderer/mod.rs
--- a/gfx/wr/webrender/src/renderer/mod.rs
+++ b/gfx/wr/webrender/src/renderer/mod.rs
@@ -867,10 +867,12 @@
buffer_damage_tracker: BufferDamageTracker,
max_primitive_instance_count: usize,
enable_instancing: bool,
+ opaque_backdrop_fallback: bool,
+
/// Count consecutive oom frames to detectif we are stuck unable to render
/// in a loop.
consecutive_oom_frames: u32,
/// update() defers processing of ResultMsg, if frame_publish_id of
@@ -2787,18 +2789,29 @@
let read_target = ReadTarget::from_texture(cache_texture);
// Should always be drawing to picture cache tiles or off-screen surface!
debug_assert!(!draw_target.is_default());
let device_to_framebuffer = Scale::new(1i32);
+ let dest_fb_rect = dest * device_to_framebuffer;
self.device.blit_render_target(
read_target,
src * device_to_framebuffer,
draw_target,
- dest * device_to_framebuffer,
+ dest_fb_rect,
TextureFilter::Linear,
);
+
+ if self.opaque_backdrop_fallback {
+ self.device.set_color_mask(false, false, false, true);
+ self.device.clear_target(
+ Some([0.0, 0.0, 0.0, 1.0]),
+ None,
+ Some(dest_fb_rect),
+ );
+ self.device.set_color_mask(true, true, true, true);
+ }
}
}
}
fn draw_picture_cache_target(
diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml
--- a/modules/libpref/init/StaticPrefList.yaml
+++ b/modules/libpref/init/StaticPrefList.yaml
@@ -8439,10 +8439,17 @@
#else
value: false
#endif
mirror: once
+# Make backdrop-filter treat its captured backdrop as if it had been
+# composited over an opaque-black background. (See bug 2036640)
+- name: gfx.webrender.opaque-backdrop-fallback
+ type: bool
+ value: true
+ mirror: once
+
# Disable wait of GPU execution completion
- name: gfx.webrender.wait-gpu-finished.disabled
type: bool
value: false
mirror: once

View File

@@ -0,0 +1,166 @@
diff --git a/browser/components/sessionstore/test/browser.toml b/browser/components/sessionstore/test/browser.toml
--- a/browser/components/sessionstore/test/browser.toml
+++ b/browser/components/sessionstore/test/browser.toml
@@ -14,10 +14,12 @@
"browser_frame_history_c.html",
"browser_frame_history_c1.html",
"browser_frame_history_c2.html",
"browser_formdata_format_sample.html",
"browser_sessionHistory_slow.sjs",
+ "browser_policy_container_sample.html",
+ "browser_policy_container_sample_frame.html",
"browser_scrollPositions_sample.html",
"browser_scrollPositions_sample2.html",
"browser_scrollPositions_sample_frameset.html",
"browser_scrollPositions_readerModeArticle.html",
"browser_sessionStorage.html",
@@ -226,10 +228,12 @@
["browser_pinned_tabs.js"]
skip-if = [
"ccov", # Bug 1625525
]
+["browser_policy_container_not_stored.js"]
+
["browser_privatetabs.js"]
["browser_purge_domaindata.js"]
["browser_purge_shistory.js"]
diff --git a/browser/components/sessionstore/test/browser_policy_container_not_stored.js b/browser/components/sessionstore/test/browser_policy_container_not_stored.js
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_policy_container_not_stored.js
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * A session history entry only needs to retain the policy container for loads
+ * that inherit their policies (about:blank, about:srcdoc, blob:, data:, ...).
+ * Anything fetched over the network gets its policies from the response again,
+ * so storing them would only bloat the session store (bug 2011236).
+ */
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const PARENT_URL = TEST_ROOT + "browser_policy_container_sample.html";
+const FRAME_URL = TEST_ROOT + "browser_policy_container_sample_frame.html";
+
+add_task(async function test_policy_container_only_for_inheriting_loads() {
+ const tab = BrowserTestUtils.addTab(gBrowser, PARENT_URL);
+ gBrowser.selectedTab = tab;
+ await promiseBrowserLoaded(tab.linkedBrowser, true, PARENT_URL);
+ await TabStateFlusher.flush(tab.linkedBrowser);
+
+ const state = JSON.parse(ss.getTabState(tab));
+ const topEntry = state.entries.at(-1);
+ is(topEntry.url, PARENT_URL, "collected the parent entry");
+
+ const children = topEntry.children ?? [];
+ const networkFrame = children.find(child => child.url === FRAME_URL);
+ const blankFrame = children.find(child => child.url === "about:blank");
+
+ ok(networkFrame, "collected the network-scheme subframe entry");
+ ok(blankFrame, "collected the about:blank subframe entry");
+
+ // The parent has a CSP, so both subframes inherit a policy container onto
+ // their load state. Only the one that cannot recover it from a response
+ // should keep it in session history.
+ ok(
+ !("policyContainer" in networkFrame),
+ "https subframe entry does not store a policyContainer"
+ );
+ ok(
+ "policyContainer" in blankFrame,
+ "about:blank subframe entry still stores its inherited policyContainer"
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/sessionstore/test/browser_policy_container_sample.html b/browser/components/sessionstore/test/browser_policy_container_sample.html
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_policy_container_sample.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="Content-Security-Policy" content="default-src * data: blob: 'unsafe-inline'">
+ <title>policyContainer session store sample</title>
+</head>
+<body>
+ <iframe id="network" src="browser_policy_container_sample_frame.html"></iframe>
+ <iframe id="blank" src="about:blank"></iframe>
+</body>
+</html>
diff --git a/browser/components/sessionstore/test/browser_policy_container_sample_frame.html b/browser/components/sessionstore/test/browser_policy_container_sample_frame.html
new file mode 100644
--- /dev/null
+++ b/browser/components/sessionstore/test/browser_policy_container_sample_frame.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>policyContainer session store sample frame</title>
+</head>
+<body>
+ frame
+</body>
+</html>
diff --git a/docshell/shistory/SessionHistoryEntry.cpp b/docshell/shistory/SessionHistoryEntry.cpp
--- a/docshell/shistory/SessionHistoryEntry.cpp
+++ b/docshell/shistory/SessionHistoryEntry.cpp
@@ -41,10 +41,18 @@
extern mozilla::LazyLogModule gSHLog;
namespace mozilla {
namespace dom {
+// Only store policy container for loads that can't carry it themselves
+// (about:blank, about:srcdoc, blob:, data:, ...)
+// bug 1867137, bug 2011236
+static nsIPolicyContainer* PolicyContainerToStore(
+ nsIURI* aURI, nsIPolicyContainer* aPolicyContainer) {
+ return CSP_ShouldURIInheritCSP(aURI) ? aPolicyContainer : nullptr;
+}
+
SessionHistoryInfo::SessionHistoryInfo(nsDocShellLoadState* aLoadState,
nsIChannel* aChannel)
: mURI(aLoadState->URI()),
mOriginalURI(aLoadState->OriginalURI()),
mResultPrincipalURI(aLoadState->ResultPrincipalURI()),
@@ -59,9 +67,10 @@
mLoadReplace(aLoadState->LoadReplace()),
mHasUserActivation(aLoadState->HasValidUserGestureActivation()),
mSharedState(SharedState::Create(
aLoadState->TriggeringPrincipal(), aLoadState->PrincipalToInherit(),
aLoadState->PartitionedPrincipalToInherit(),
- aLoadState->PolicyContainer(),
+ PolicyContainerToStore(aLoadState->URI(),
+ aLoadState->PolicyContainer()),
/* FIXME Is this correct? */
aLoadState->TypeHint())) {
// Pull the upload stream off of the channel instead of the load state, as
@@ -124,11 +133,12 @@
loadInfo->GetPrincipalToInherit(
getter_AddRefs(mSharedState.Get()->mPrincipalToInherit));
mSharedState.Get()->mPartitionedPrincipalToInherit =
aPartitionedPrincipalToInherit;
- mSharedState.Get()->mPolicyContainer = aPolicyContainer;
+ mSharedState.Get()->mPolicyContainer =
+ PolicyContainerToStore(mURI, aPolicyContainer);
aChannel->GetContentType(mSharedState.Get()->mContentType);
aChannel->GetOriginalURI(getter_AddRefs(mOriginalURI));
uint32_t loadFlags;
aChannel->GetLoadFlags(&loadFlags);

View File

@@ -0,0 +1,55 @@
diff --git a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
--- a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
+++ b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs
@@ -119,10 +119,19 @@
}
case "PictureInPicture:VideoTabHidden": {
if (!lazy.PIP_ENABLED || !lazy.PIP_WHEN_SWITCHING_TABS) {
break;
}
+
+ // Suppress auto-toggle if the user deliberately closed this PiP window
+ if (
+ PictureInPicture.weakAutoPipBrowserClosedDeliberately.has(browser)
+ ) {
+ PictureInPicture.weakAutoPipBrowserClosedDeliberately.delete(browser);
+ break;
+ }
+
// If the tab is still selected, then we can ignore this event
if (browser.documentGlobal.gBrowser.selectedBrowser == browser) {
break;
}
let actor = browsingContext.currentWindowGlobal.getActor(
@@ -261,10 +270,12 @@
maxConcurrentPlayerCount: 0,
// Maps auto pip browser to PictureInPictureParent actor
weakAutoPipBrowserToParent: new WeakMap(),
+ weakAutoPipBrowserClosedDeliberately: new WeakSet(),
+
/**
* Returns the player window if one exists and if it hasn't yet been closed.
*
* @param {PictureInPictureParent} pipActorRef
* Reference to the calling PictureInPictureParent actor
@@ -899,10 +910,17 @@
const { reason, actorRef } = closeData;
const win = this.getWeakPipPlayer(actorRef);
if (!win) {
return;
}
+
+ const browser = this.weakWinToBrowser.get(win);
+
+ if (reason === "CloseButton") {
+ PictureInPicture.weakAutoPipBrowserClosedDeliberately.add(browser);
+ }
+
this.removePiPBrowserFromWeakMap(this.weakWinToBrowser.get(win));
this.weakAutoPipBrowserToParent.delete(this.weakWinToBrowser.get(win));
Glean.pictureinpicture["closedMethod" + reason].record();
await this.closePipWindow(win);

View File

@@ -7,14 +7,6 @@
"id": "D299584", "id": "D299584",
"name": "Native MacOS popovers fix" "name": "Native MacOS popovers fix"
}, },
{
"type": "phabricator",
"id": "D284404",
"name": "Add urlbar closeOnWindowBlur preference",
"replaces": {
"\n\n": "\n // may want to figure out a more robust way to detect abandonment."
}
},
{ {
"type": "local", "type": "local",
// TODO: Convert into https://phabricator.services.mozilla.com/D298079 // TODO: Convert into https://phabricator.services.mozilla.com/D298079
@@ -48,5 +40,21 @@
"this._lastJsonLength = uncompressedBytes;": "this._lastJsonLength = uncompressedBytes.jsonLength;", "this._lastJsonLength = uncompressedBytes;": "this._lastJsonLength = uncompressedBytes.jsonLength;",
" jsonLengthHint,": " lengthHint: jsonLengthHint," " jsonLengthHint,": " lengthHint: jsonLengthHint,"
} }
},
{
"type": "phabricator",
"id": "D320897",
"name": "Bug 2011236",
"replaces": {
// The upstream patch context includes a MOZ_DIAGNOSTIC_ASSERT that
// doesn't exist yet in our Firefox version.
"@@ -59,11 +67,12 @@": "@@ -59,9 +67,10 @@",
"aLoadState->TypeHint())) {\n MOZ_DIAGNOSTIC_ASSERT(!mURI->SchemeIs(\"javascript\"));\n \n // Pull": "aLoadState->TypeHint())) {\n // Pull"
}
},
{
"type": "phabricator",
"id": "D321446",
"name": "Issue 14710"
} }
] ]

View File

@@ -1,15 +0,0 @@
diff --git a/services/sync/modules/service.sys.mjs b/services/sync/modules/service.sys.mjs
index 4f4efe572909441abdd81909282936702c225621..81ab06a9414ed656b644a21b40b683f06a4f8dbf 100644
--- a/services/sync/modules/service.sys.mjs
+++ b/services/sync/modules/service.sys.mjs
@@ -99,6 +99,10 @@ function getEngineModules() {
whenTrue: "ExtensionStorageEngineKinto",
whenFalse: "ExtensionStorageEngineBridge",
};
+ result.Spaces = {
+ module: "resource:///modules/zen/ZenWorkspacesSync.sys.mjs",
+ symbol: "ZenWorkspacesEngine",
+ };
return result;
}

View File

@@ -0,0 +1,14 @@
diff --git a/testing/xpcshell/runxpcshelltests.py b/testing/xpcshell/runxpcshelltests.py
index 403401613189ffb758037850dc6bbb1d55f194ab..cad28e245155eb54775e551885532026e56c5fa9 100755
--- a/testing/xpcshell/runxpcshelltests.py
+++ b/testing/xpcshell/runxpcshelltests.py
@@ -2275,6 +2275,9 @@ class XPCShellTests:
appDirKey = None
if "appname" in self.mozInfo:
appDirKey = self.mozInfo["appname"] + "-appdir"
+ if appDirKey == "zen-appdir":
+ # Inherited manifests use firefox-appdir.
+ appDirKey = "firefox-appdir"
# We have to do this before we run tests that depend on having the node
# http/2 server.

View File

@@ -1,37 +0,0 @@
diff --git a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
index 5702ff28cc22206f5ce16584dac8a78d816562ce..3d08ecc97ce5995b30d8a4af0c33df329b428008 100644
--- a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
+++ b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
@@ -270,11 +270,29 @@ _ContextualIdentityService.prototype = {
});
},
- create(name, icon, color) {
+ create(name, icon, color, id = null) {
this.ensureDataReady();
- // Retrieve the next userContextId available.
- let userContextId = ++this._lastUserContextId;
+ if (id !== null) {
+ id = typeof id == "string" ? Number(id) : id;
+ if (!Number.isSafeInteger(id) || id <= 0) {
+ throw new Error(`Invalid userContextId '${id}'`);
+ }
+ }
+
+ let userContextId;
+ if (
+ id !== null &&
+ !this._identities.some(identity => identity.userContextId === id)
+ ) {
+ userContextId = id;
+ this._lastUserContextId = Math.max(
+ this._lastUserContextId,
+ userContextId
+ );
+ } else {
+ userContextId = ++this._lastUserContextId;
+ }
// Throw an error if the next userContextId available is invalid (the one associated to
// MAX_USER_CONTEXT_ID is already reserved to "userContextIdInternal.webextStorageLocal", which

View File

@@ -0,0 +1,29 @@
diff --git a/toolkit/content/widgets/panel-list/panel-list.mjs b/toolkit/content/widgets/panel-list/panel-list.mjs
index 68ad2fafc8b31dfff541cf8381d9b9533e376d1c..fa62fec6bfb33338091fc06d3736fb76fd5c47c4 100644
--- a/toolkit/content/widgets/panel-list/panel-list.mjs
+++ b/toolkit/content/widgets/panel-list/panel-list.mjs
@@ -223,7 +223,12 @@ export class PanelList extends HTMLElement {
this.setAttribute("showing", "true");
// Tell the host element to hide any overflow in case the panel extends off
// the page before the alignment is set.
- hostElement.style.overflow = "hidden";
+ const hostHidesOverflow = !getComputedStyle(hostElement)
+ .overflow.split(" ")
+ .includes("visible");
+ if (!hostHidesOverflow) {
+ hostElement.style.overflow = "hidden";
+ }
// Wait for a layout flush, then find the bounds.
let {
@@ -344,7 +349,9 @@ export class PanelList extends HTMLElement {
// Set the alignments and show the panel.
this.setAttribute("align", align);
this.setAttribute("valign", valign);
- hostElement.style.overflow = "";
+ if (!hostHidesOverflow) {
+ hostElement.style.overflow = "";
+ }
// Decide positioning based on where this panel will be rendered
const offsetParentIsBody =
this.supportsPopover() ||

View File

@@ -832,8 +832,9 @@ window.gZenCompactModeManager = {
const onLeave = event => { const onLeave = event => {
if (AppConstants.platform == "macosx") { if (AppConstants.platform == "macosx") {
const buttonRect = const buttonRect = window.windowUtils.getBoundsWithoutFlushing(
gZenVerticalTabsManager.actualWindowButtons.getBoundingClientRect(); gZenVerticalTabsManager.actualWindowButtons
);
const MAC_WINDOW_BUTTONS_X_BORDER = buttonRect.width + buttonRect.x; const MAC_WINDOW_BUTTONS_X_BORDER = buttonRect.width + buttonRect.x;
const MAC_WINDOW_BUTTONS_Y_BORDER = buttonRect.height + buttonRect.y; const MAC_WINDOW_BUTTONS_Y_BORDER = buttonRect.height + buttonRect.y;
if ( if (

View File

@@ -346,7 +346,7 @@ class ZenMediaCard {
onToggle() { onToggle() {
if (this.controller?.isPlaying) { if (this.controller?.isPlaying) {
this.controller.pause(); this.controller.pause("user");
} else { } else {
this.controller?.play(); this.controller?.play();
} }
@@ -365,7 +365,7 @@ class ZenMediaCard {
} }
onSeekDrag(event) { onSeekDrag(event) {
this.controller?.pause(); this.controller?.pause("user");
const newTime = (event.target.value / 100) * this.duration; const newTime = (event.target.value / 100) * this.duration;
this.currentTimeEl.textContent = this.formatSecondsToTime(newTime); this.currentTimeEl.textContent = this.formatSecondsToTime(newTime);
} }
@@ -408,7 +408,7 @@ class ZenMediaCard {
onClose() { onClose() {
if (this.controller) { if (this.controller) {
this.controller.pause(); this.controller.pause("user");
} else { } else {
const webRTC = const webRTC =
this.browser.browsingContext.currentWindowGlobal.getActor("WebRTC"); this.browser.browsingContext.currentWindowGlobal.getActor("WebRTC");

View File

@@ -21,6 +21,5 @@ DIRS += [
"share", "share",
"spaces", "spaces",
"space-routing", "space-routing",
"sync",
"window-drag", "window-drag",
] ]

View File

@@ -10,7 +10,6 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
ZenLiveFoldersManager: ZenLiveFoldersManager:
"resource:///modules/zen/ZenLiveFoldersManager.sys.mjs", "resource:///modules/zen/ZenLiveFoldersManager.sys.mjs",
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs",
@@ -611,7 +610,6 @@ export class nsZenSessionManager {
} }
); );
this.#collectWindowData(windows); this.#collectWindowData(windows);
lazy.ZenSyncStore.notifyAboutChanges();
// This would save the data to disk asynchronously or when quitting the app. // This would save the data to disk asynchronously or when quitting the app.
let sidebar = this.#sidebarWithoutCloning; let sidebar = this.#sidebarWithoutCloning;
this.#file.data = sidebar; this.#file.data = sidebar;
@@ -625,15 +623,6 @@ export class nsZenSessionManager {
this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`); this.log(`Saving Zen session data with ${sidebar.tabs?.length || 0} tabs`);
} }
/**
* Returns the sidebar data object.
*
* @returns {object}
*/
getSidebarData() {
return this.#sidebarWithoutCloning;
}
/** /**
* Called when the last known backup should be deleted and a new one * Called when the last known backup should be deleted and a new one
* created. This uses the #deferredBackupTask to debounce clusters of * created. This uses the #deferredBackupTask to debounce clusters of

View File

@@ -27,7 +27,7 @@ class nsZenSpaceRoutingManager {
const element = window.MozXULElement.parseXULToFragment(` const element = window.MozXULElement.parseXULToFragment(`
<menuseparator/> <menuseparator/>
<menuitem id="context_zen-add-domain-to-routing" <menuitem id="context_zen-add-domain-to-routing"
data-lazy-l10n-id="tab-context-zen-add-domain-to-sr" data-l10n-id="tab-context-zen-add-domain-to-sr"
data-l10n-args='{"tabCount": 1}'/> data-l10n-args='{"tabCount": 1}'/>
`); `);
window.document.getElementById("context_undoCloseTab").after(element); window.document.getElementById("context_undoCloseTab").after(element);

View File

@@ -11,7 +11,6 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, { ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
}); });
ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => { ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => {
@@ -153,7 +152,7 @@ class nsZenWorkspaces {
if (!this.privateWindowOrDisabled) { if (!this.privateWindowOrDisabled) {
const observerFunction = async () => { const observerFunction = async () => {
delete this._workspaceBookmarksCache; delete this._workspaceBookmarksCache;
await this.#initializeWorkspaceBookmarks(); await this.workspaceBookmarks();
this._invalidateBookmarkContainers(); this._invalidateBookmarkContainers();
}; };
Services.obs.addObserver(observerFunction, "workspace-bookmarks-updated"); Services.obs.addObserver(observerFunction, "workspace-bookmarks-updated");
@@ -173,63 +172,6 @@ class nsZenWorkspaces {
} }
} }
/**
* Applies live sync changes: updates workspace cache, removes deleted items,
* then creates/updates pulled items.
*
* @param {{ spaces: Array}} pulled Reconcile-pulled items.
* @param {{ spaces: Array}} removals Items to remove.
*/
async _applySyncChanges(pulled, removals = {}) {
if (!this.shouldHaveWorkspaces || this.privateWindowOrDisabled) {
return;
}
await this.promiseInitialized;
// 1. Update workspace cache (remove deleted, merge pulled)
const removedSpaceIds = new Set((removals.spaces || []).map(s => s.uuid));
if (removedSpaceIds.size || pulled.spaces?.length) {
const localMap = new Map(
this.getWorkspaces()
.filter(w => !removedSpaceIds.has(w.uuid))
.map(w => [w.uuid, w])
);
for (const space of pulled.spaces || []) {
if (!space?.uuid) {
continue;
}
const existing = localMap.get(space.uuid);
localMap.set(space.uuid, existing ? { ...existing, ...space } : space);
}
await this.propagateWorkspaces(
this.#getOrderedWorkspacesByPosition(Array.from(localMap.values()))
);
this.#propagateWorkspaceData();
}
}
#getOrderedWorkspacesByPosition(workspaces) {
return [...workspaces]
.map((workspace, index) => ({ workspace, index }))
.sort((a, b) => {
const aPosition =
typeof a.workspace.position === "number"
? a.workspace.position
: a.index;
const bPosition =
typeof b.workspace.position === "number"
? b.workspace.position
: b.index;
return aPosition - bPosition || a.index - b.index;
})
.map(({ workspace }) => {
// strip the position property that comes from pulled workspaces
const rest = { ...workspace };
delete rest.position;
return rest;
});
}
#afterLoadInit() { #afterLoadInit() {
const onResize = (...args) => { const onResize = (...args) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -757,17 +699,17 @@ class nsZenWorkspaces {
return spacesForSS; return spacesForSS;
} }
async #initializeWorkspaceBookmarks() { async workspaceBookmarks() {
if (this.privateWindowOrDisabled) { if (this.privateWindowOrDisabled) {
this._workspaceBookmarksCache = { this._workspaceBookmarksCache = {
bookmarks: [], bookmarks: [],
lastChangeTimestamp: 0, lastChangeTimestamp: 0,
}; };
return; return this._workspaceBookmarksCache;
} }
if (this._workspaceBookmarksCache) { if (this._workspaceBookmarksCache) {
return; return this._workspaceBookmarksCache;
} }
const [bookmarks, lastChangeTimestamp] = await Promise.all([ const [bookmarks, lastChangeTimestamp] = await Promise.all([
@@ -776,6 +718,8 @@ class nsZenWorkspaces {
]); ]);
this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp }; this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp };
return this._workspaceCache;
} }
restoreWorkspacesFromSessionStore(aWinData = {}) { restoreWorkspacesFromSessionStore(aWinData = {}) {
@@ -836,7 +780,7 @@ class nsZenWorkspaces {
return (async () => { return (async () => {
await this.#waitForPromises(); await this.#waitForPromises();
this.#afterLoadInit(); this.#afterLoadInit();
await this.#initializeWorkspaceBookmarks(); await this.workspaceBookmarks();
await this.changeWorkspace(activeWorkspace, { onInit: true }); await this.changeWorkspace(activeWorkspace, { onInit: true });
this.#fixTabPositions(); this.#fixTabPositions();
this.onWindowResize(); this.onWindowResize();
@@ -862,13 +806,6 @@ class nsZenWorkspaces {
})(); })();
} }
#markWorkspaceChanged(workspaceId) {
lazy.ZenSyncStore.markItemChanged({
type: "space",
id: workspaceId,
});
}
async selectStartPage() { async selectStartPage() {
if (!this.workspaceEnabled || gZenUIManager.testingEnabled) { if (!this.workspaceEnabled || gZenUIManager.testingEnabled) {
return; return;
@@ -1297,19 +1234,12 @@ class nsZenWorkspaces {
} else { } else {
workspacesData.push(workspaceData); workspacesData.push(workspaceData);
} }
// mark item as changed for sync
this.#markWorkspaceChanged(workspaceData.uuid);
this.#propagateWorkspaceData(); this.#propagateWorkspaceData();
} }
removeWorkspace(windowID) { removeWorkspace(windowID) {
let { promise, resolve } = Promise.withResolvers(); let { promise, resolve } = Promise.withResolvers();
this.#deleteWorkspaceOwnedTabs(windowID); this.#deleteWorkspaceOwnedTabs(windowID);
// mark item as changed for sync
this.#markWorkspaceChanged(windowID);
let workspacesData = this.getWorkspaces(); let workspacesData = this.getWorkspaces();
// Remove the workspace from the cache // Remove the workspace from the cache
workspacesData = workspacesData.filter( workspacesData = workspacesData.filter(
@@ -1441,50 +1371,30 @@ class nsZenWorkspaces {
if (this.privateWindowOrDisabled) { if (this.privateWindowOrDisabled) {
return; return;
} }
const workspaces = this.getWorkspaces(); const workspaces = this._workspaceCache;
// Track previous positions so we only notify observers for workspaces whose
// position changed during the reorder.
const previousPositions = new Map(
workspaces.map((workspace, index) => [workspace.uuid, index])
);
const workspace = workspaces.find(w => w.uuid === id); const workspace = workspaces.find(w => w.uuid === id);
if (!workspace) { if (!workspace) {
console.warn(`Workspace with ID ${id} not found for reordering.`); console.warn(`Workspace with ID ${id} not found for reordering.`);
return; return;
} }
// Remove the workspace from its current position // Remove the workspace from its current position
const currentIndex = workspaces.indexOf(workspace); const currentIndex = workspaces.indexOf(workspace);
if (currentIndex === -1) { if (currentIndex === -1) {
console.warn(`Workspace with ID ${id} not found in the list.`); console.warn(`Workspace with ID ${id} not found in the list.`);
return; return;
} }
workspaces.splice(currentIndex, 1);
// Insert the workspace at the new position // Insert the workspace at the new position
if (newPosition < 0 || newPosition >= workspaces.length) { if (newPosition < 0 || newPosition > workspaces.length) {
console.warn( console.warn(
`Invalid position ${newPosition} for reordering workspace with ID ${id}.` `Invalid position ${newPosition} for reordering workspace with ID ${id}.`
); );
return; return;
} }
workspaces.splice(currentIndex, 1);
workspaces.splice(newPosition, 0, workspace); workspaces.splice(newPosition, 0, workspace);
// Propagate the changes if the order has changed // Propagate the changes if the order has changed
if (currentIndex !== newPosition) { if (currentIndex !== newPosition) {
this._workspaceCache = workspaces; this.#propagateWorkspaceData();
for (const [i, ws] of workspaces.entries()) {
if (previousPositions.get(ws.uuid) === i) {
continue;
}
// mark item as changed for sync
this.#markWorkspaceChanged(ws.uuid);
}
this.#propagateWorkspaceData(workspaces);
} }
} }
@@ -1790,6 +1700,15 @@ class nsZenWorkspaces {
} }
makeSureEmptyTabIsFirst() { makeSureEmptyTabIsFirst() {
if (
gZenUIManager.testingEnabled &&
this._emptyTab &&
(this._emptyTab.closing || !this._emptyTab.isConnected)
) {
this._emptyTab = gBrowser.tabs.find(
tab => tab.hasAttribute("zen-empty-tab") && !tab.closing
);
}
const emptyTab = this._emptyTab; const emptyTab = this._emptyTab;
if (emptyTab) { if (emptyTab) {
emptyTab.setAttribute("zen-workspace-id", this.activeWorkspace); emptyTab.setAttribute("zen-workspace-id", this.activeWorkspace);
@@ -2459,7 +2378,7 @@ class nsZenWorkspaces {
for (const tab of gBrowser.tabs) { for (const tab of gBrowser.tabs) {
if ( if (
!tab.hasAttribute("zen-workspace-id") && !tab.hasAttribute("zen-workspace-id") &&
!tab.hasAttribute("zen-essential") !tab.hasAttribute("zen-workspace-id")
) { ) {
tab.setAttribute("zen-workspace-id", workspace.uuid); tab.setAttribute("zen-workspace-id", workspace.uuid);
} }
@@ -3320,8 +3239,10 @@ class nsZenWorkspaces {
return width; return width;
}, 0); }, 0);
// Check if the total width exceeds the parent's width // Check if the total width exceeds the parent's width.
if (totalWidth > parent.clientWidth) { const parentWidth =
window.windowUtils.getBoundsWithoutFlushing(parent).width;
if (totalWidth > parentWidth) {
parent.setAttribute("icons-overflow", "true"); parent.setAttribute("icons-overflow", "true");
} else { } else {
parent.removeAttribute("icons-overflow"); parent.removeAttribute("icons-overflow");
@@ -3329,7 +3250,7 @@ class nsZenWorkspaces {
// Set the width of each icon to the maximum size they can fit on // Set the width of each icon to the maximum size they can fit on
const widthPerButton = Math.max( const widthPerButton = Math.max(
(parent.clientWidth - separation * (parent.children.length - 1)) / (parentWidth - separation * (parent.children.length - 1)) /
parent.children.length, parent.children.length,
minButtonSize minButtonSize
); );

View File

@@ -230,7 +230,10 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
} }
this.removeGroup(groupIndex); this.removeGroup(groupIndex);
if (changeTab) { if (changeTab) {
gBrowser.selectedTab = remainingTabs[0]; const tabToSelect = remainingTabs.find(remaining => remaining !== tab);
if (tabToSelect) {
gBrowser.selectedTab = tabToSelect;
}
document document
.getElementById("cmd_zenNewEmptySplit") .getElementById("cmd_zenNewEmptySplit")
.removeAttribute("disabled"); .removeAttribute("disabled");

View File

@@ -1,160 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
ContextualIdentityService:
"resource://gre/modules/ContextualIdentityService.sys.mjs",
ZenWindowSync: "resource:///modules/zen/ZenWindowSync.sys.mjs",
});
function normalizeUserContextId(value) {
const normalized = typeof value === "string" ? Number(value) : value;
if (!Number.isSafeInteger(normalized) || normalized <= 0) {
return null;
}
return normalized;
}
class ZenSyncManager {
getSidebarData() {
return lazy.ZenSessionStore.getSidebarData();
}
/**
* Whether to ignore changes to items. This is used to prevent
* infinite loops when applying incoming sync changes.
*
* @type {boolean}
*/
#ignoreChanges = false;
#changedItems = new Map();
markItemChanged(item) {
if (item.type && item.id && !this.#ignoreChanges) {
const key = `${item.type}~${item.id}`;
this.#changedItems.set(key, { type: item.type, id: item.id });
}
}
#getChangedItems() {
return Array.from(this.#changedItems.values());
}
#clearChangedItems() {
this.#changedItems.clear();
}
notifyAboutChanges() {
const changedItems = this.#getChangedItems();
for (const item of changedItems) {
Services.obs.notifyObservers(
{ wrappedJSObject: item },
"zen-workspace-item-changed"
);
}
this.#clearChangedItems();
}
async applyIncomingBatch(pulled, removals) {
try {
this.#ignoreChanges = true;
this.#applyIncomingContainers(
pulled.containers || [],
removals.containers || []
);
const win = lazy.ZenWindowSync.firstSyncedWindow;
if (win?.gZenWorkspaces) {
await win.gZenWorkspaces._applySyncChanges(pulled, removals);
}
} catch (e) {
console.error("ZenSyncManager: Failed to apply incoming sync data:", e);
throw e;
} finally {
this.#ignoreChanges = false;
}
}
#applyIncomingContainers(pulledContainers, removedContainers) {
const localContainersById = new Map(
lazy.ContextualIdentityService.getPublicIdentities().map(container => [
container.userContextId,
container,
])
);
for (const container of pulledContainers) {
if (!container.name) {
continue;
}
const userContextId = normalizeUserContextId(container.userContextId);
if (userContextId === null) {
console.warn(
"ZenSyncManager: Ignoring incoming container with invalid userContextId",
{ container }
);
continue;
}
const existsLocally = localContainersById.has(userContextId);
if (existsLocally) {
lazy.ContextualIdentityService.update(
userContextId,
container.name,
container.icon,
container.color
);
continue;
}
const createdIdentity = lazy.ContextualIdentityService.create(
container.name,
container.icon,
container.color,
userContextId
);
if (createdIdentity) {
localContainersById.set(createdIdentity.userContextId, createdIdentity);
}
if (createdIdentity && createdIdentity.userContextId !== userContextId) {
console.warn("ZenSyncManager: Container sync created unexpected ID", {
requestedId: userContextId,
createdId: createdIdentity.userContextId,
name: container.name,
});
}
}
for (const container of removedContainers) {
const userContextId = normalizeUserContextId(container.userContextId);
if (userContextId === null) {
console.warn(
"ZenSyncManager: Ignoring container removal with invalid userContextId",
{ container }
);
continue;
}
if (!localContainersById.has(userContextId)) {
continue;
}
try {
lazy.ContextualIdentityService.remove(userContextId);
localContainersById.delete(userContextId);
} catch {
// Container may already be gone locally.
}
}
}
}
export const ZenSyncStore = new ZenSyncManager();

View File

@@ -1,404 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import {
Store,
SyncEngine,
Tracker,
} from "resource://services-sync/engines.sys.mjs";
import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ZenSyncStore: "resource:///modules/zen/ZenSyncManager.sys.mjs",
ContextualIdentityService:
"resource://gre/modules/ContextualIdentityService.sys.mjs",
});
const RECORD_ID_PREFIX_BY_TYPE = Object.freeze({
space: "s",
container: "c",
});
const RECORD_TYPE_BY_PREFIX = Object.freeze({
s: "space",
c: "container",
});
/**
* Sync record wrapper for workspace and container items stored in the
* Workspaces engine collection.
*/
export class ZenWorkspacesRecord extends CryptoWrapper {
_logName = "Sync.Record.ZenSpaces";
}
ZenWorkspacesRecord.prototype.type = "spaces";
function parseRecordId(id) {
const sep = id.indexOf("~");
if (sep === -1) {
return null;
}
const prefix = id.slice(0, sep);
const key = id.slice(sep + 1);
return { type: RECORD_TYPE_BY_PREFIX[prefix] || prefix, key };
}
function createRecordId(type, id) {
const prefix = RECORD_ID_PREFIX_BY_TYPE[type];
if (!prefix) {
throw new Error(`Unknown Spaces Sync record type: ${type}`);
}
return `${prefix}~${id}`;
}
function normalizeUserContextId(value) {
const normalized = typeof value === "string" ? Number(value) : value;
if (!Number.isSafeInteger(normalized) || normalized <= 0) {
return null;
}
return normalized;
}
/**
* Strips the sync-envelope fields (`id` and `type`) from incoming record data
* and restores the item's real identity key where needed
*
* @param {object} data
*/
function stripSyncFields(data) {
const rest = { ...data };
delete rest.id;
delete rest.type;
return rest;
}
/**
* Sync store implementation that serializes local workspace and container
* state into records and applies incoming remote changes.
*/
class ZenWorkspacesStore extends Store {
constructor(name, engine) {
super(name, engine);
}
async getAllIDs() {
const ids = {};
const sidebar = lazy.ZenSyncStore.getSidebarData();
for (const space of sidebar.spaces || []) {
if (space.uuid) {
ids[createRecordId("space", space.uuid)] = true;
}
}
for (const c of lazy.ContextualIdentityService.getPublicIdentities()) {
ids[createRecordId("container", c.userContextId)] = true;
}
return ids;
}
async itemExists(id) {
const parsed = parseRecordId(id);
if (!parsed) {
return false;
}
const sidebar = lazy.ZenSyncStore.getSidebarData();
switch (parsed.type) {
case "space":
return (sidebar.spaces || []).some(s => s.uuid === parsed.key);
case "container":
return lazy.ContextualIdentityService.getPublicIdentities().some(
c => String(c.userContextId) === parsed.key
);
default:
return false;
}
}
async createRecord(id, collection) {
const record = new ZenWorkspacesRecord(collection, id);
const parsed = parseRecordId(id);
if (!parsed) {
record.deleted = true;
return record;
}
const sidebar = lazy.ZenSyncStore.getSidebarData();
switch (parsed.type) {
case "space": {
const spaces = sidebar.spaces || [];
const idx = spaces.findIndex(s => s.uuid === parsed.key);
if (idx === -1) {
record.deleted = true;
return record;
}
const rest = { ...spaces[idx] };
delete rest.syncStatus;
record.cleartext = { id, type: "space", ...rest, position: idx };
break;
}
case "container": {
const container =
lazy.ContextualIdentityService.getPublicIdentities().find(
c => String(c.userContextId) === parsed.key
);
if (!container) {
record.deleted = true;
return record;
}
record.cleartext = {
id,
type: "container",
userContextId: container.userContextId,
name: container.name,
icon: container.icon,
color: container.color,
};
break;
}
default:
record.deleted = true;
}
return record;
}
async applyIncomingBatch(records, _countTelemetry) {
const pulled = { spaces: [], containers: [] };
const removals = { spaces: [], containers: [] };
for (const record of records) {
if (record.deleted) {
this._collectRemoval(record.id, removals);
continue;
}
const data = record.cleartext;
if (!data?.type) {
continue;
}
const clean = stripSyncFields(data);
switch (data.type) {
case "space":
pulled.spaces.push(clean);
break;
case "container":
pulled.containers.push(clean);
break;
}
}
// Suppress change tracking while applying incoming data to prevent
// feedback loops where applied items get re-uploaded immediately.
this.engine._tracker.ignoreAll = true;
try {
await lazy.ZenSyncStore.applyIncomingBatch(pulled, removals);
} finally {
this.engine._tracker.ignoreAll = false;
}
return [];
}
_collectRemoval(id, removals) {
const parsed = parseRecordId(id);
if (!parsed) {
return;
}
switch (parsed.type) {
case "space":
removals.spaces.push({ uuid: parsed.key });
break;
case "container": {
const userContextId = normalizeUserContextId(parsed.key);
if (userContextId === null) {
console.warn(
"ZenWorkspacesStore: Ignoring container removal with invalid userContextId",
{ id }
);
break;
}
removals.containers.push({ userContextId });
break;
}
}
}
async create(record) {
await this._applySingle(record);
}
async update(record) {
await this._applySingle(record);
}
async _applySingle(record) {
this.engine._tracker.ignoreAll = true;
try {
if (record.deleted) {
const removals = { spaces: [], containers: [] };
this._collectRemoval(record.id, removals);
await lazy.ZenSyncStore.applyIncomingBatch(
{ spaces: [], containers: [] },
removals
);
return;
}
const data = record.cleartext;
if (!data?.type) {
return;
}
const clean = stripSyncFields(data);
const pulled = { spaces: [], containers: [] };
switch (data.type) {
case "space":
pulled.spaces.push(clean);
break;
case "container":
pulled.containers.push(clean);
break;
}
await lazy.ZenSyncStore.applyIncomingBatch(pulled, {
spaces: [],
containers: [],
});
} finally {
this.engine._tracker.ignoreAll = false;
}
}
async remove() {
// No-op: never delete user data on wipe
}
async wipe() {
// No-op: never delete user data on wipe
}
changeItemID() {
// No-op
}
}
/**
* Sync tracker that watches workspace and contextual identity observers and
* marks the corresponding record IDs as changed.
*/
class ZenWorkspacesTracker extends Tracker {
#changedIDs = {};
#ignoreAll = false;
get ignoreAll() {
return this.#ignoreAll;
}
set ignoreAll(value) {
this.#ignoreAll = value;
}
onStart() {
Services.obs.addObserver(this, "zen-workspace-item-changed");
Services.obs.addObserver(this, "contextual-identity-created");
Services.obs.addObserver(this, "contextual-identity-updated");
Services.obs.addObserver(this, "contextual-identity-deleted");
}
onStop() {
Services.obs.removeObserver(this, "zen-workspace-item-changed");
Services.obs.removeObserver(this, "contextual-identity-created");
Services.obs.removeObserver(this, "contextual-identity-updated");
Services.obs.removeObserver(this, "contextual-identity-deleted");
}
observe(subject, topic, _data) {
if (this.#ignoreAll) {
return;
}
if (topic === "zen-workspace-item-changed") {
const type = subject?.wrappedJSObject?.type;
const id = subject?.wrappedJSObject?.id;
if (type && id) {
this._trackChange({ type, id });
}
} else if (topic.startsWith("contextual-identity-")) {
const id = subject?.wrappedJSObject?.userContextId;
if (id && normalizeUserContextId(id) !== null) {
this._trackChange({ type: "container", id });
}
}
}
_trackChange(data) {
if (data.type && data.id) {
const id = createRecordId(data.type, data.id);
this.#changedIDs[id] = Date.now() / 1000;
this.score += SCORE_INCREMENT_XLARGE;
}
}
async getChangedIDs() {
return { ...this.#changedIDs };
}
async addChangedID(id, when) {
this.#changedIDs[id] = when;
return true;
}
async removeChangedID(...ids) {
for (const id of ids) {
delete this.#changedIDs[id];
}
return true;
}
clearChangedIDs() {
this.#changedIDs = {};
}
}
/**
* Sync engine entrypoint that wires the Workspaces record, store, and tracker
* implementations into Firefox Sync.
*/
export class ZenWorkspacesEngine extends SyncEngine {
static get name() {
return "Spaces";
}
constructor(service) {
super("Spaces", service);
}
get _storeObj() {
return ZenWorkspacesStore;
}
get _trackerObj() {
return ZenWorkspacesTracker;
}
get _recordObj() {
return ZenWorkspacesRecord;
}
get version() {
return 2;
}
get syncPriority() {
return 8;
}
get allowSkippedRecord() {
return false;
}
}

View File

@@ -1,8 +0,0 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
EXTRA_JS_MODULES.zen += [
"ZenSyncManager.sys.mjs",
"ZenWorkspacesSync.sys.mjs",
]

View File

@@ -538,8 +538,7 @@
:root[zen-single-toolbar="true"]:not([zen-has-empty-tab="true"]) & { :root[zen-single-toolbar="true"]:not([zen-has-empty-tab="true"]) & {
& #urlbar:not([breakout-extend]) .urlbar-input-container { & #urlbar:not([breakout-extend]) .urlbar-input-container {
padding-left: 8px; padding-inline: 4px;
padding-right: 4px;
} }
} }
} }

View File

@@ -3,6 +3,36 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this # License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
[alerts]
source = "browser/base/content/test/alerts"
is_direct_path = true
[backforward]
source = "browser/base/content/test/backforward"
is_direct_path = true
[caps]
source = "browser/base/content/test/caps"
is_direct_path = true
[gesture]
source = "browser/base/content/test/gesture"
is_direct_path = true
[gesture.replace-manifest]
"[DEFAULT]" = '''[DEFAULT]
prefs = [
"zen.window-sync.enabled=false",
]'''
[linkHandling]
source = "browser/base/content/test/linkHandling"
is_direct_path = true
disable = [
# Glance opens with alt+click, firefox doesnt know that.
"browser_javascript_links.js",
]
[readermode] [readermode]
source = "toolkit/components/reader/tests/browser" source = "toolkit/components/reader/tests/browser"
is_direct_path = true is_direct_path = true
@@ -13,10 +43,88 @@ disable = [
"browser_readerMode_colorSchemePref.js", "browser_readerMode_colorSchemePref.js",
] ]
[remote-settings]
source = "services/settings/test/unit"
is_direct_path = true
xpcshell = true
[remote-settings.replace-manifest]
"../../../" = "../../../../services/"
[safebrowsing] [safebrowsing]
source = "browser/components/safebrowsing/content/test" source = "browser/components/safebrowsing/content/test"
is_direct_path = true is_direct_path = true
[services-crypto]
source = "services/crypto/tests/unit"
is_direct_path = true
xpcshell = true
[services-crypto.replace-manifest]
"../../../" = "../../../../services/"
[sessionstore]
source = "browser/components/sessionstore/test"
is_direct_path = true
disable = [
"browser_1933485_tab_groups_history.js",
"browser_1953801_tab_groups_history_close_other.js",
"browser_aboutSessionRestore.js",
"browser_bfcache_telemetry.js",
"browser_closedId.js",
"browser_closed_objects_changed_notifications_tabs.js",
"browser_closed_objects_changed_notifications_windows.js",
"browser_closed_tabs_closed_windows.js",
"browser_closed_tabs_windows.js",
"browser_cookies_partitioned.js",
"browser_crashedTabs.js",
"browser_dying_cache.js",
"browser_firefoxView_restore.js",
"browser_firefoxView_selected_restore.js",
"browser_forget_closed_tab_window_byId.js",
"browser_movePendingTabToNewWindow.js",
"browser_newtab_userTypedValue.js",
"browser_pinned_tabs.js",
"browser_purge_domaindata.js",
"browser_reopen_all_windows.js",
"browser_restoreLastActionCorrectOrder.js",
"browser_restoreLastClosedTabOrWindowOrSession.js",
"browser_restore_container_tabs_oa.js",
"browser_restore_tabless_window.js",
"browser_restore_verticalPinnedTabs.js",
"browser_restored_window_features.js",
"browser_scrollPositions.js",
"browser_scrollPositionsReaderMode.js",
"browser_searchModeSwitcher_restore.js",
"browser_speculative_connect.js",
"browser_splitview_integer_ids.js",
"browser_splitview_restore_in_closed_window.js",
"browser_splitview_string_migration.js",
"browser_swapDocShells.js",
"browser_tab_groups_restore_closed_in_closed_window.js",
"browser_tab_groups_restore_closed_in_open_window.js",
"browser_tab_groups_restore_closed_many_tabs.js",
"browser_tab_groups_closed.js",
"browser_tab_groups_restore_multiple.js",
"browser_tab_groups_restore_saved.js",
"browser_tab_groups_restore_simple.js",
"browser_tab_groups_save_on_removeAllTabsBut.js",
"browser_tab_groups_save_on_removeTabsToTheEnd.js",
"browser_tab_groups_save_on_removeTabsToTheStart.js",
"browser_tab_groups_save_on_window_close.js",
"browser_tab_groups_state.js",
"browser_tab_groups_undo.js",
"browser_tab_label_during_restore.js",
"browser_undoCloseById.js",
"browser_urlbarSearchMode.js",
"browser_windowStateContainer.js",
"browser_wireframe_basic.js",
]
[sessionstore.replace-manifest]
'prefs = [' = '''prefs = [
"zen.window-sync.enabled=false",'''
[shell] [shell]
source = "browser/components/shell/test" source = "browser/components/shell/test"
is_direct_path = true is_direct_path = true
@@ -25,6 +133,15 @@ disable = [
"browser_setDesktopBackgroundPreview.js", "browser_setDesktopBackgroundPreview.js",
] ]
[sync]
source = "services/sync/tests/unit"
is_direct_path = true
xpcshell = true
[sync.replace-manifest]
"../../../" = "../../../../services/"
'"identity.fxaccounts.enabled=true"' = '"identity.fxaccounts.enabled=true", "browser.formfill.enable=true"'
[tabMediaIndicator] [tabMediaIndicator]
source = "browser/components/tabbrowser/test/browser/tabMediaIndicator" source = "browser/components/tabbrowser/test/browser/tabMediaIndicator"
is_direct_path = true is_direct_path = true
@@ -34,3 +151,10 @@ source = "toolkit/components/tooltiptext"
disable = [ disable = [
"browser_input_file_tooltips.js", "browser_input_file_tooltips.js",
] ]
[zoom]
source = "browser/base/content/test/zoom"
is_direct_path = true
[zoom.replace-manifest]
"../general/" = "../../../../browser/base/content/test/general/"

View File

@@ -0,0 +1,32 @@
[DEFAULT]
support-files = [
"head.js",
"file_dom_notifications.html",
]
["browser_notification_close.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1227785
]
["browser_notification_do_not_disturb.js"]
https_first_disabled = true
["browser_notification_open_settings.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1411118
]
["browser_notification_remove_permission.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1411118
]
["browser_notification_tab_switching.js"]
https_first_disabled = true
skip-if = [
"os == 'win'", # Bug 1243263
]

View File

@@ -0,0 +1,78 @@
"use strict";
const { PlacesTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PlacesTestUtils.sys.mjs"
);
const { PermissionTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PermissionTestUtils.sys.mjs"
);
let notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
add_task(async function test_notificationClose() {
await addNotificationPermission(notificationURL);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: notificationURL,
},
async function dummyTabTask(aBrowser) {
await openNotification(aBrowser, "showNotification2");
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
await closeNotification(aBrowser);
return;
}
let alertTitleLabel =
alertWindow.document.getElementById("alertTitleLabel");
is(
alertTitleLabel.value,
"Test title",
"Title text of notification should be present"
);
let alertTextLabel =
alertWindow.document.getElementById("alertTextLabel");
is(
alertTextLabel.textContent,
"Test body 2",
"Body text of notification should be present"
);
let alertCloseButton = alertWindow.document.querySelector(".close-icon");
is(alertCloseButton.localName, "toolbarbutton", "close button found");
let promiseBeforeUnloadEvent = BrowserTestUtils.waitForEvent(
alertWindow,
"beforeunload"
);
let closedTime = alertWindow.Date.now();
alertCloseButton.click();
info("Clicked on close button");
await promiseBeforeUnloadEvent;
ok(true, "Alert should close when the close button is clicked");
let currentTime = alertWindow.Date.now();
// The notification will self-close at 12 seconds, so this checks
// that the notification closed before the timeout.
Assert.less(
currentTime - closedTime,
5000,
"Close requested at " +
closedTime +
", actually closed at " +
currentTime
);
}
);
});
add_task(async function cleanup() {
PermissionTestUtils.remove(notificationURL, "desktop-notification");
});

View File

@@ -0,0 +1,159 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/**
* Tests that notifications can be silenced using nsIAlertsDoNotDisturb
* on systems where that interface and its methods are implemented for
* the nsIAlertService.
*/
const ALERT_SERVICE = Cc["@mozilla.org/alerts-service;1"]
.getService(Ci.nsIAlertsService)
.QueryInterface(Ci.nsIAlertsDoNotDisturb);
const PAGE =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
// The amount of time in seconds that we will wait for a notification
// to show up before we decide that it's not coming.
const NOTIFICATION_TIMEOUT_SECS = 2000;
add_setup(async function () {
await addNotificationPermission(PAGE);
});
/**
* Test that the manualDoNotDisturb attribute can prevent
* notifications from appearing.
*/
add_task(async function test_manualDoNotDisturb() {
try {
// Only run the test if the do-not-disturb
// interface has been implemented.
ALERT_SERVICE.manualDoNotDisturb;
ok(true, "Alert service implements do-not-disturb interface");
} catch (e) {
ok(
true,
"Alert service doesn't implement do-not-disturb interface, exiting test"
);
return;
}
// In the event that something goes wrong during this test, make sure
// we put the attribute back to the default setting when this test file
// exits.
registerCleanupFunction(() => {
ALERT_SERVICE.manualDoNotDisturb = false;
});
// Make sure that do-not-disturb is not enabled before we start.
ok(
!ALERT_SERVICE.manualDoNotDisturb,
"Alert service should not be disabled when test starts"
);
await BrowserTestUtils.withNewTab(PAGE, async browser => {
await openNotification(browser, "showNotification2");
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
// For now, only the XUL alert backend implements the manualDoNotDisturb
// method for nsIAlertsDoNotDisturb, so we expect there to be a XUL alert
// window. If the method gets implemented by native backends in the future,
// we'll probably want to branch here and set the manualDoNotDisturb
// attribute manually.
ok(alertWindow, "Expected a XUL alert window.");
// We're using the XUL notification backend. This means that there's
// a menuitem for enabling manualDoNotDisturb. We exercise that
// menuitem here.
let doNotDisturbMenuItem = alertWindow.document.getElementById(
"doNotDisturbMenuItem"
);
is(doNotDisturbMenuItem.localName, "menuitem", "menuitem found");
let unloadPromise = BrowserTestUtils.waitForEvent(
alertWindow,
"beforeunload"
);
doNotDisturbMenuItem.click();
info("Clicked on do-not-disturb menuitem");
await unloadPromise;
// At this point, we should be configured to not display notifications
// to the user.
ok(
ALERT_SERVICE.manualDoNotDisturb,
"Alert service should be disabled after clicking menuitem"
);
// The notification should not appear, but there is no way from the
// client-side to know that it was blocked, except for waiting some time
// and realizing that the "onshow" event never fired.
await Assert.rejects(
openNotification(browser, "showNotification2", NOTIFICATION_TIMEOUT_SECS),
/timed out/,
"The notification should never display."
);
ALERT_SERVICE.manualDoNotDisturb = false;
});
});
/**
* Test that the suppressForScreenSharing attribute can prevent
* notifications from appearing.
*/
add_task(async function test_suppressForScreenSharing() {
try {
// Only run the test if the do-not-disturb
// interface has been implemented.
ALERT_SERVICE.suppressForScreenSharing;
ok(true, "Alert service implements do-not-disturb interface");
} catch (e) {
ok(
true,
"Alert service doesn't implement do-not-disturb interface, exiting test"
);
return;
}
// In the event that something goes wrong during this test, make sure
// we put the attribute back to the default setting when this test file
// exits.
registerCleanupFunction(() => {
ALERT_SERVICE.suppressForScreenSharing = false;
});
// Make sure that do-not-disturb is not enabled before we start.
ok(
!ALERT_SERVICE.suppressForScreenSharing,
"Alert service should not be suppressing for screen sharing when test " +
"starts"
);
await BrowserTestUtils.withNewTab(PAGE, async browser => {
await openNotification(browser, "showNotification2");
info("Notification alert showing");
await closeNotification(browser);
ALERT_SERVICE.suppressForScreenSharing = true;
// The notification should not appear, but there is no way from the
// client-side to know that it was blocked, except for waiting some time
// and realizing that the "onshow" event never fired.
await Assert.rejects(
openNotification(browser, "showNotification2", NOTIFICATION_TIMEOUT_SECS),
/timed out/,
"The notification should never display."
);
});
ALERT_SERVICE.suppressForScreenSharing = false;
});

View File

@@ -0,0 +1,87 @@
"use strict";
var notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
// BrowserGlue passes "privacy-permissions" to openPreferences when the
// notifications-open-settings observer fires; the Settings Redesign
// LegacyPaneMappings shim routes that to the permissionsData pane.
var expectedURL = Services.prefs.getBoolPref(
"browser.settings-redesign.enabled",
false
)
? "about:preferences#permissionsData"
: "about:preferences#privacy";
add_task(async function test_settingsOpen_observer() {
info(
"Opening a dummy tab so openPreferences=>switchToTabHavingURI doesn't use the blank tab."
);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: "about:robots",
},
async function dummyTabTask() {
// Ensure preferences is loaded before removing the tab.
let syncPaneLoadedPromise = TestUtils.topicObserved(
"sync-pane-loaded",
() => true
);
let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
info("simulate a notifications-open-settings notification");
let uri = NetUtil.newURI("https://example.com");
let principal = Services.scriptSecurityManager.createContentPrincipal(
uri,
{}
);
Services.obs.notifyObservers(principal, "notifications-open-settings");
let tab = await tabPromise;
ok(tab, "The notification settings tab opened");
await syncPaneLoadedPromise;
BrowserTestUtils.removeTab(tab);
}
);
});
add_task(async function test_settingsOpen_button() {
info("Adding notification permission");
await addNotificationPermission(notificationURL);
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: notificationURL,
},
async function tabTask(aBrowser) {
info("Waiting for notification");
await openNotification(aBrowser, "showNotification2");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
await closeNotification(aBrowser);
return;
}
// Ensure preferences is loaded before removing the tab.
let syncPaneLoadedPromise = TestUtils.topicObserved(
"sync-pane-loaded",
() => true
);
let closePromise = promiseWindowClosed(alertWindow);
let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
let openSettingsMenuItem = alertWindow.document.getElementById(
"openSettingsMenuItem"
);
openSettingsMenuItem.click();
info("Waiting for notification settings tab");
let tab = await tabPromise;
ok(tab, "The notification settings tab opened");
await syncPaneLoadedPromise;
await closePromise;
BrowserTestUtils.removeTab(tab);
}
);
});

View File

@@ -0,0 +1,85 @@
"use strict";
const { PermissionTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PermissionTestUtils.sys.mjs"
);
var tab;
var notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
var alertWindowClosed = false;
var permRemoved = false;
function test() {
waitForExplicitFinish();
registerCleanupFunction(function () {
gBrowser.removeTab(tab);
window.restore();
});
addNotificationPermission(notificationURL).then(function openTab() {
tab = BrowserTestUtils.addTab(gBrowser, notificationURL);
gBrowser.selectedTab = tab;
BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => onLoad());
});
}
function onLoad() {
openNotification(tab.linkedBrowser, "showNotification2").then(onAlertShowing);
}
function onAlertShowing() {
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
closeNotification(tab.linkedBrowser).then(finish);
return;
}
ok(
PermissionTestUtils.testExactPermission(
notificationURL,
"desktop-notification"
),
"Permission should exist prior to removal"
);
let disableForOriginMenuItem = alertWindow.document.getElementById(
"disableForOriginMenuItem"
);
is(disableForOriginMenuItem.localName, "menuitem", "menuitem found");
Services.obs.addObserver(permObserver, "perm-changed");
alertWindow.addEventListener("beforeunload", onAlertClosing);
disableForOriginMenuItem.click();
info("Clicked on disable-for-origin menuitem");
}
function permObserver(subject, topic, data) {
if (topic != "perm-changed") {
return;
}
let permission = subject.QueryInterface(Ci.nsIPermission);
is(
permission.type,
"desktop-notification",
"desktop-notification permission changed"
);
is(data, "deleted", "desktop-notification permission deleted");
Services.obs.removeObserver(permObserver, "perm-changed");
permRemoved = true;
if (alertWindowClosed) {
finish();
}
}
function onAlertClosing(event) {
event.target.removeEventListener("beforeunload", onAlertClosing);
alertWindowClosed = true;
if (permRemoved) {
finish();
}
}

View File

@@ -0,0 +1,116 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/
*/
"use strict";
const { PermissionTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PermissionTestUtils.sys.mjs"
);
var tab;
var notification;
var notificationURL =
"https://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
var newWindowOpenedFromTab;
add_task(async function test_notificationPreventDefaultAndSwitchTabs() {
await addNotificationPermission(notificationURL);
let originalTab = gBrowser.selectedTab;
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: notificationURL,
},
async function dummyTabTask(aBrowser) {
// Put new tab in background so it is obvious when it is re-focused.
await BrowserTestUtils.switchTab(gBrowser, originalTab);
isnot(
gBrowser.selectedBrowser,
aBrowser,
"Notification page loaded as a background tab"
);
// First, show a notification that will be have the tab-switching prevented.
function promiseNotificationEvent(evt) {
return SpecialPowers.spawn(
aBrowser,
[evt],
async function (contentEvt) {
return new Promise(resolve => {
let contentNotification = content.wrappedJSObject._notification;
contentNotification.addEventListener(
contentEvt,
function (event) {
resolve({ defaultPrevented: event.defaultPrevented });
},
{ once: true }
);
});
}
);
}
await openNotification(aBrowser, "showNotification1");
info("Notification alert showing");
let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
if (!alertWindow) {
ok(true, "Notifications don't use XUL windows on all platforms.");
await closeNotification(aBrowser);
return;
}
info("Clicking on notification");
let promiseClickEvent = promiseNotificationEvent("click");
// NB: This executeSoon is needed to allow the non-e10s runs of this test
// a chance to set the event listener on the page. Otherwise, we
// synchronously fire the click event before we listen for the event.
executeSoon(() => {
EventUtils.synthesizeMouseAtCenter(
alertWindow.document.getElementById("alertTitleLabel"),
{},
alertWindow
);
});
let clickEvent = await promiseClickEvent;
ok(
clickEvent.defaultPrevented,
"The event handler for the first notification cancels the event"
);
isnot(
gBrowser.selectedBrowser,
aBrowser,
"Notification page still a background tab"
);
let notificationClosed = promiseNotificationEvent("close");
await closeNotification(aBrowser);
await notificationClosed;
// Second, show a notification that will cause the tab to get switched.
await openNotification(aBrowser, "showNotification2");
alertWindow = Services.wm.getMostRecentWindow("alert:alert");
let promiseTabSelect = BrowserTestUtils.waitForEvent(
gBrowser.tabContainer,
"TabSelect"
);
EventUtils.synthesizeMouseAtCenter(
alertWindow.document.getElementById("alertTitleLabel"),
{},
alertWindow
);
await promiseTabSelect;
is(
gBrowser.selectedBrowser.currentURI.spec,
notificationURL,
"Clicking on the second notification should select its originating tab"
);
notificationClosed = promiseNotificationEvent("close");
await closeNotification(aBrowser);
await notificationClosed;
}
);
});
add_task(async function cleanup() {
PermissionTestUtils.remove(notificationURL, "desktop-notification");
});

View File

@@ -0,0 +1,39 @@
<html>
<head>
<meta charset="utf-8">
<script>
"use strict";
function showNotification1() {
var options = {
dir: undefined,
lang: undefined,
body: "Test body 1",
tag: "Test tag",
icon: undefined,
};
var n = new Notification("Test title", options);
n.addEventListener("click", function(event) {
event.preventDefault();
});
return n;
}
function showNotification2() {
var options = {
dir: undefined,
lang: undefined,
body: "Test body 2",
tag: "Test tag",
icon: undefined,
};
return new Notification("Test title", options);
}
</script>
</head>
<body>
<form id="notificationForm" onsubmit="showNotification();">
<input type="submit" value="Show notification" id="submit"/>
</form>
</body>
</html>

View File

@@ -0,0 +1,73 @@
// Platforms may default to reducing motion. We override this to ensure the
// alert slide animation is enabled in tests.
SpecialPowers.pushPrefEnv({
set: [["ui.prefersReducedMotion", 0]],
});
async function addNotificationPermission(originString) {
return SpecialPowers.pushPermissions([
{
type: "desktop-notification",
allow: true,
context: originString,
},
]);
}
/**
* Similar to `BrowserTestUtils.closeWindow`, but
* doesn't call `window.close()`.
*/
function promiseWindowClosed(window) {
return new Promise(function (resolve) {
Services.ww.registerNotification(function observer(subject, topic) {
if (topic == "domwindowclosed" && subject == window) {
Services.ww.unregisterNotification(observer);
resolve();
}
});
});
}
/**
* These two functions work with file_dom_notifications.html to open the
* notification and close it.
*
* |fn| can be showNotification1 or showNotification2.
* if |timeout| is passed, then the promise returned from this function is
* rejected after the requested number of miliseconds.
*/
function openNotification(aBrowser, fn, timeout) {
info(`openNotification: ${fn}`);
return SpecialPowers.spawn(
aBrowser,
[fn, timeout],
async function (contentFn, contentTimeout) {
await new Promise((resolve, reject) => {
let win = content.wrappedJSObject;
let notification = win[contentFn]();
win._notification = notification;
function listener() {
notification.removeEventListener("show", listener);
resolve();
}
notification.addEventListener("show", listener);
if (contentTimeout) {
content.setTimeout(() => {
notification.removeEventListener("show", listener);
reject("timed out");
}, contentTimeout);
}
});
}
);
}
function closeNotification(aBrowser) {
return SpecialPowers.spawn(aBrowser, [], function () {
content.wrappedJSObject._notification.close();
});
}

View File

@@ -0,0 +1,7 @@
[DEFAULT]
["browser_history_menu.js"]
fail-if = [
"a11y_checks", # Bug 1854233 navigator-toolbox may not be focusable
]
https_first_disabled = true

View File

@@ -0,0 +1,179 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
// This test verifies that the back forward button long-press menu and context menu
// shows the correct history items.
add_task(async function mousedown_back() {
await testBackForwardMenu(false);
});
add_task(async function contextmenu_back() {
await testBackForwardMenu(true);
});
async function openHistoryMenu(useContextMenu) {
let backButton = document.getElementById("back-button");
let rect = backButton.getBoundingClientRect();
info("waiting for the history menu to open");
let popupShownPromise = BrowserTestUtils.waitForEvent(
useContextMenu ? document.getElementById("backForwardMenu") : backButton,
"popupshown"
);
if (useContextMenu) {
EventUtils.synthesizeMouseAtCenter(backButton, {
type: "contextmenu",
button: 2,
});
} else {
EventUtils.synthesizeMouseAtCenter(backButton, { type: "mousedown" });
}
EventUtils.synthesizeMouse(backButton, rect.width / 2, rect.height, {
type: "mouseup",
});
let popupEvent = await popupShownPromise;
ok(true, "history menu opened");
return popupEvent;
}
async function testBackForwardMenu(useContextMenu) {
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
"http://example.com"
);
for (let iter = 2; iter <= 4; iter++) {
// Iterate three times. For the first two times through the loop, add a new history item.
// But for the last iteration, go back in the history instead.
await SpecialPowers.spawn(
gBrowser.selectedBrowser,
[iter],
async function (iterChild) {
if (iterChild == 4) {
let popStatePromise = new Promise(function (resolve) {
content.onpopstate = resolve;
});
content.history.back();
await popStatePromise;
} else {
// With the back-button intervention enabled, we need to make sure to
// trigger a user activation on each history entry, or they won't
// show in the menu.
content.document.notifyUserGestureActivation();
content.history.pushState({}, "" + iterChild, iterChild + ".html");
}
}
);
// Wait for the session data to be flushed before continuing the test
await new Promise(resolve =>
SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
);
let popupEvent = await openHistoryMenu(useContextMenu);
// Wait for the session data to be flushed before continuing the test
await new Promise(resolve =>
SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
);
is(
popupEvent.target.children.length,
iter > 3 ? 3 : iter,
"Correct number of history items"
);
let node = popupEvent.target.lastElementChild;
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
is(node.getAttribute("uri"), "http://example.com/", "'1' item uri");
is(node.getAttribute("index"), "0", "'1' item index");
is(
node.getAttribute("historyindex"),
iter == 3 ? "-2" : "-1",
"'1' item historyindex"
);
node = node.previousElementSibling;
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
is(node.getAttribute("uri"), "http://example.com/2.html", "'2' item uri");
is(node.getAttribute("index"), "1", "'2' item index");
is(
node.getAttribute("historyindex"),
iter == 3 ? "-1" : "0",
"'2' item historyindex"
);
if (iter >= 3) {
node = node.previousElementSibling;
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
is(node.getAttribute("uri"), "http://example.com/3.html", "'3' item uri");
is(node.getAttribute("index"), "2", "'3' item index");
is(
node.getAttribute("historyindex"),
iter == 4 ? "1" : "0",
"'3' item historyindex"
);
}
// Close the popup, but on the last iteration, click on one of the history items
// to ensure it opens in a new tab.
let popupHiddenPromise = BrowserTestUtils.waitForEvent(
popupEvent.target,
"popuphidden"
);
if (iter < 4) {
popupEvent.target.hidePopup();
} else {
let newTabPromise = BrowserTestUtils.waitForNewTab(
gBrowser,
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
url => url == "http://example.com/"
);
popupEvent.target.activateItem(popupEvent.target.children[2], {
button: 1,
});
let newtab = await newTabPromise;
gBrowser.removeTab(newtab);
}
await popupHiddenPromise;
}
gBrowser.removeTab(tab);
}
// Make sure that the history popup appears after navigating around in a preferences page.
add_task(async function test_preferences_page() {
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"about:preferences"
);
openPreferences("search");
let popupEvent = await openHistoryMenu(true);
// Wait for the session data to be flushed before continuing the test
await new Promise(resolve =>
SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
);
is(popupEvent.target.children.length, 2, "Correct number of history items");
let popupHiddenPromise = BrowserTestUtils.waitForEvent(
popupEvent.target,
"popuphidden"
);
popupEvent.target.hidePopup();
await popupHiddenPromise;
gBrowser.removeTab(tab);
});

View File

@@ -0,0 +1,10 @@
[DEFAULT]
["browser_principalSerialization_csp.js"]
["browser_principalSerialization_json.js"]
skip-if = [
"debug", # deliberately bypass assertions when deserializing. Bug 965637 removed the CSP from Principals, but the remaining bits in such Principals should deserialize correctly.
]
["browser_principalSerialization_version1.js"]

View File

@@ -0,0 +1,106 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/*
* Within Bug 965637 we move the CSP away from the Principal. Serialized Principals however
* might still have CSPs serialized within them. This tests ensures that we do not
* encounter a memory corruption when deserializing. It's fine that the deserialized
* CSP is null, but the Principal itself should deserialize correctly.
*/
add_task(async function test_deserialize_principal_with_csp() {
/*
This test should be resilient to changes in principal serialization, if these are failing then it's likely the code will break session storage.
To recreate this for another version, copy the function into the browser console, browse some pages and printHistory.
Generated with:
function printHistory() {
let tests = [];
let entries = SessionStore.getSessionHistory(gBrowser.selectedTab).entries.map((entry) => { return entry.triggeringPrincipal_base64 });
entries.push(E10SUtils.serializePrincipal(gBrowser.selectedTab.linkedBrowser._contentPrincipal));
for (let entry of entries) {
console.log(entry);
let testData = {};
testData.input = entry;
let principal = E10SUtils.deserializePrincipal(testData.input);
testData.output = {};
if (principal.URI === null) {
testData.output.URI = false;
} else {
testData.output.URISpec = principal.URI.spec;
}
testData.output.originAttributes = principal.originAttributes;
testData.output.cspJSON = principal.cspJSON;
tests.push(testData);
}
return tests;
}
printHistory(); // Copy this into: serializedPrincipalsFromFirefox
*/
let serializedPrincipalsFromFirefox = [
{
input:
"ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAHmh0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTLwAAAAAAAAAFAAAACAAAAA8AAAAA/////wAAAAD/////AAAACAAAAA8AAAAXAAAABwAAABcAAAAHAAAAFwAAAAcAAAAeAAAAAAAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AQAAAAAAAAAAAAAAAQnZ7Rrl1EAEv+Anzrkj2ayzxMCuvV5MrYfgjSENuz+fAd6UctCANBHTk5kAEEug/UCSBzpUbXhPMJE6uHGBMgjGAAAAAv////8AAAG7AQAAAB5odHRwczovL3d3dy5tb3ppbGxhLm9yZy9lbi1VUy8AAAAAAAAABQAAAAgAAAAPAAAAAP////8AAAAA/////wAAAAgAAAAPAAAAFwAAAAcAAAAXAAAABwAAABcAAAAHAAAAHgAAAAAAAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wEAAAAAAAAAAAABAAAFtgBzAGMAcgBpAHAAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIAAnAHUAbgBzAGEAZgBlAC0AaQBuAGwAaQBuAGUAJwAgACcAdQBuAHMAYQBmAGUALQBlAHYAYQBsACcAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdABhAGcAbQBhAG4AYQBnAGUAcgAuAGcAbwBvAGcAbABlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AcwAuAHkAdABpAG0AZwAuAGMAbwBtADsAIABpAG0AZwAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIABkAGEAdABhADoAIABoAHQAdABwAHMAOgAvAC8AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAZABzAGUAcgB2AGkAYwBlAC4AZwBvAG8AZwBsAGUALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AZABlACAAaAB0AHQAcABzADoALwAvAGEAZABzAGUAcgB2AGkAYwBlAC4AZwBvAG8AZwBsAGUALgBkAGsAIABoAHQAdABwAHMAOgAvAC8AYwByAGUAYQB0AGkAdgBlAGMAbwBtAG0AbwBuAHMALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwBhAGQALgBkAG8AdQBiAGwAZQBjAGwAaQBjAGsALgBuAGUAdAA7ACAAZABlAGYAYQB1AGwAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AOwAgAGYAcgBhAG0AZQAtAHMAcgBjACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAtAG4AbwBjAG8AbwBrAGkAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAcgBhAGMAawBlAHIAdABlAHMAdAAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AcwB1AHIAdgBlAHkAZwBpAHoAbQBvAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAuAGMAbgAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALgBjAG8AbQA7ACAAcwB0AHkAbABlAC0AcwByAGMAIAAnAHMAZQBsAGYAJwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG4AZQB0ACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBjAG8AbQAgACcAdQBuAHMAYQBmAGUALQBpAG4AbABpAG4AZQAnADsAIABjAG8AbgBuAGUAYwB0AC0AcwByAGMAIAAnAHMAZQBsAGYAJwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG4AZQB0ACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALwAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALgBjAG4ALwA7ACAAYwBoAGkAbABkAC0AcwByAGMAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC0AbgBvAGMAbwBvAGsAaQBlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdAByAGEAYwBrAGUAcgB0AGUAcwB0AC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBzAHUAcgB2AGUAeQBnAGkAegBtAG8ALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC4AYwBuACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAuAGMAbwBtAAA=",
output: {
// Within Bug 965637 we removed CSP from Principals. Already serialized Principals however should still deserialize correctly (just without the CSP).
// "cspJSON": "{\"csp-policies\":[{\"child-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"connect-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://accounts.firefox.com/\",\"https://accounts.firefox.com.cn/\"],\"default-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\"],\"frame-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"img-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"data:\",\"https://mozilla.org\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://adservice.google.com\",\"https://adservice.google.de\",\"https://adservice.google.dk\",\"https://creativecommons.org\",\"https://ad.doubleclick.net\"],\"report-only\":false,\"script-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\",\"'unsafe-eval'\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://tagmanager.google.com\",\"https://www.youtube.com\",\"https://s.ytimg.com\"],\"style-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\"]}]}",
URISpec: "https://www.mozilla.org/en-US/",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input:
"ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAL2h0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTL2ZpcmVmb3gvYWNjb3VudHMvAAAAAAAAAAUAAAAIAAAADwAAAAj/////AAAACP////8AAAAIAAAADwAAABcAAAAYAAAAFwAAABgAAAAXAAAAGAAAAC8AAAAAAAAAL/////8AAAAA/////wAAABf/////AAAAF/////8BAAAAAAAAAAAAAAABCdntGuXUQAS/4CfOuSPZrLPEwK69Xkyth+CNIQ27P58B3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAL2h0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTL2ZpcmVmb3gvYWNjb3VudHMvAAAAAAAAAAUAAAAIAAAADwAAAAj/////AAAACP////8AAAAIAAAADwAAABcAAAAYAAAAFwAAABgAAAAXAAAAGAAAAC8AAAAAAAAAL/////8AAAAA/////wAAABf/////AAAAF/////8BAAAAAAAAAAAAAQAABbYAcwBjAHIAaQBwAHQALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtACAAJwB1AG4AcwBhAGYAZQAtAGkAbgBsAGkAbgBlACcAIAAnAHUAbgBzAGEAZgBlAC0AZQB2AGEAbAAnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBnAG8AbwBnAGwAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHMALgB5AHQAaQBtAGcALgBjAG8AbQA7ACAAaQBtAGcALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtACAAZABhAHQAYQA6ACAAaAB0AHQAcABzADoALwAvAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBkAHMAZQByAHYAaQBjAGUALgBnAG8AbwBnAGwAZQAuAGQAZQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AZABrACAAaAB0AHQAcABzADoALwAvAGMAcgBlAGEAdABpAHYAZQBjAG8AbQBtAG8AbgBzAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AYQBkAC4AZABvAHUAYgBsAGUAYwBsAGkAYwBrAC4AbgBlAHQAOwAgAGQAZQBmAGEAdQBsAHQALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtADsAIABmAHIAYQBtAGUALQBzAHIAYwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALQBuAG8AYwBvAG8AawBpAGUALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB0AHIAYQBjAGsAZQByAHQAZQBzAHQALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHMAdQByAHYAZQB5AGcAaQB6AG0AbwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALgBjAG4AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC4AYwBvAG0AOwAgAHMAdAB5AGwAZQAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIAAnAHUAbgBzAGEAZgBlAC0AaQBuAGwAaQBuAGUAJwA7ACAAYwBvAG4AbgBlAGMAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC8AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC4AYwBuAC8AOwAgAGMAaABpAGwAZAAtAHMAcgBjACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAtAG4AbwBjAG8AbwBrAGkAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAcgBhAGMAawBlAHIAdABlAHMAdAAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AcwB1AHIAdgBlAHkAZwBpAHoAbQBvAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAuAGMAbgAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALgBjAG8AbQAA",
output: {
URISpec: "https://www.mozilla.org/en-US/firefox/accounts/",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
// Within Bug 965637 we removed CSP from Principals. Already serialized Principals however should still deserialize correctly (just without the CSP).
// "cspJSON": "{\"csp-policies\":[{\"child-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"connect-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://accounts.firefox.com/\",\"https://accounts.firefox.com.cn/\"],\"default-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\"],\"frame-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"img-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"data:\",\"https://mozilla.org\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://adservice.google.com\",\"https://adservice.google.de\",\"https://adservice.google.dk\",\"https://creativecommons.org\",\"https://ad.doubleclick.net\"],\"report-only\":false,\"script-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\",\"'unsafe-eval'\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://tagmanager.google.com\",\"https://www.youtube.com\",\"https://s.ytimg.com\"],\"style-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\"]}]}",
},
},
];
for (let test of serializedPrincipalsFromFirefox) {
let principal = E10SUtils.deserializePrincipal(test.input);
for (let key in principal.originAttributes) {
is(
principal.originAttributes[key],
test.output.originAttributes[key],
`Ensure value of ${key} is ${test.output.originAttributes[key]}`
);
}
if ("URI" in test.output && test.output.URI === false) {
is(
principal.isContentPrincipal,
false,
"Should have not have a URI for system"
);
} else {
is(
principal.spec,
test.output.URISpec,
`Should have spec ${test.output.URISpec}`
);
}
}
});

View File

@@ -0,0 +1,161 @@
"use strict";
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/*
This test file exists to ensure whenever changes to principal serialization happens,
we guarantee that the data can be restored and generated into a new principal.
The tests are written to be brittle so we encode all versions of the changes into the tests.
*/
add_task(async function test_nullPrincipal() {
const nullId = "0";
// fields
const uri = 0;
const suffix = 1;
const nullReplaceRegex =
/moz-nullprincipal:{[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}}/;
const NULL_REPLACE = "NULL_PRINCIPAL_URL";
/*
This test should NOT be resilient to changes in versioning,
however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
*/
let tests = [
{
input: { OA: {} },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
{
input: { OA: {} },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
{
input: { OA: { userContextId: 0 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
{
input: { OA: { userContextId: 2 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}","${suffix}":"^userContextId=2"}}`,
},
{
input: { OA: { privateBrowsingId: 1 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}","${suffix}":"^privateBrowsingId=1"}}`,
},
{
input: { OA: { privateBrowsingId: 0 } },
expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
},
];
for (let test of tests) {
let p = Services.scriptSecurityManager.createNullPrincipal(test.input.OA);
let sp = E10SUtils.serializePrincipal(p);
// Not sure why cppjson is adding a \n here
let spr = sp.replace(nullReplaceRegex, NULL_REPLACE);
is(
test.expected,
spr,
"Expected serialized object for " + JSON.stringify(test.input)
);
let dp = E10SUtils.deserializePrincipal(sp);
// Check all the origin attributes
for (let key in test.input.OA) {
is(
dp.originAttributes[key],
test.input.OA[key],
"Ensure value of " + key + " is " + test.input.OA[key]
);
}
}
});
add_task(async function test_contentPrincipal() {
const contentId = "1";
// fields
const content = 0;
// const domain = 1;
const suffix = 2;
// const csp = 3;
/*
This test should NOT be resilient to changes in versioning,
however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
*/
let tests = [
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://example.com/", OA: {} },
expected: `{"${contentId}":{"${content}":"http://example.com/"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla1.com/", OA: {} },
expected: `{"${contentId}":{"${content}":"http://mozilla1.com/"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla2.com/", OA: { userContextId: 0 } },
expected: `{"${contentId}":{"${content}":"http://mozilla2.com/"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla3.com/", OA: { userContextId: 2 } },
expected: `{"${contentId}":{"${content}":"http://mozilla3.com/","${suffix}":"^userContextId=2"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla4.com/", OA: { privateBrowsingId: 1 } },
expected: `{"${contentId}":{"${content}":"http://mozilla4.com/","${suffix}":"^privateBrowsingId=1"}}`,
},
{
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
input: { uri: "http://mozilla5.com/", OA: { privateBrowsingId: 0 } },
expected: `{"${contentId}":{"${content}":"http://mozilla5.com/"}}`,
},
];
for (let test of tests) {
let uri = Services.io.newURI(test.input.uri);
let p = Services.scriptSecurityManager.createContentPrincipal(
uri,
test.input.OA
);
let sp = E10SUtils.serializePrincipal(p);
is(test.expected, sp, "Expected serialized object for " + test.input.uri);
let dp = E10SUtils.deserializePrincipal(sp);
is(dp.URI.spec, test.input.uri, "Ensure spec is the same");
// Check all the origin attributes
for (let key in test.input.OA) {
is(
dp.originAttributes[key],
test.input.OA[key],
"Ensure value of " + key + " is " + test.input.OA[key]
);
}
}
});
add_task(async function test_systemPrincipal() {
const systemId = "3";
/*
This test should NOT be resilient to changes in versioning,
however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
*/
const expected = `{"${systemId}":{}}`;
let p = Services.scriptSecurityManager.getSystemPrincipal();
let sp = E10SUtils.serializePrincipal(p);
is(expected, sp, "Expected serialized object for system principal");
let dp = E10SUtils.deserializePrincipal(sp);
is(
dp,
Services.scriptSecurityManager.getSystemPrincipal(),
"Deserialized the system principal"
);
});

View File

@@ -0,0 +1,159 @@
"use strict";
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/*
This test file exists to ensure whenever changes to principal serialization happens,
we guarantee that the data can be restored and generated into a new principal.
The tests are written to be brittle so we encode all versions of the changes into the tests.
*/
add_task(function test_nullPrincipal() {
/*
As Null principals are designed to be non deterministic we just need to ensure that
a previous serialized version matches what it was generated as.
This test should be resilient to changes in versioning, however it should also be duplicated for a new serialization change.
*/
// Principal created with: E10SUtils.serializePrincipal(Services.scriptSecurityManager.createNullPrincipal({ }));
let p = E10SUtils.deserializePrincipal(
"vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezU2Y2FjNTQwLTg2NGQtNDdlNy04ZTI1LTE2MTRlYWI1MTU1ZX0AAAAA"
);
is(
"moz-nullprincipal:{56cac540-864d-47e7-8e25-1614eab5155e}",
p.URI.spec,
"Deserialized principal doesn't have the correct URI"
);
// Principal created with: E10SUtils.serializePrincipal(Services.scriptSecurityManager.createNullPrincipal({ userContextId: 2 }));
let p2 = E10SUtils.deserializePrincipal(
"vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezA1ZjllN2JhLWIwODMtNDJhMi1iNDdkLTZiODRmNmYwYTM3OX0AAAAQXnVzZXJDb250ZXh0SWQ9Mg=="
);
is(
"moz-nullprincipal:{05f9e7ba-b083-42a2-b47d-6b84f6f0a379}",
p2.URI.spec,
"Deserialized principal doesn't have the correct URI"
);
is(p2.originAttributes.userContextId, 2, "Expected a userContextId of 2");
});
add_task(async function test_realHistoryCheck() {
/*
This test should be resilient to changes in principal serialization, if these are failing then it's likely the code will break session storage.
To recreate this for another version, copy the function into the browser console, browse some pages and printHistory.
Generated with:
function printHistory() {
let tests = [];
let entries = SessionStore.getSessionHistory(gBrowser.selectedTab).entries.map((entry) => { return entry.triggeringPrincipal_base64 });
entries.push(E10SUtils.serializePrincipal(gBrowser.selectedTab.linkedBrowser._contentPrincipal));
for (let entry of entries) {
console.log(entry);
let testData = {};
testData.input = entry;
let principal = E10SUtils.deserializePrincipal(testData.input);
testData.output = {};
if (principal.URI === null) {
testData.output.URI = false;
} else {
testData.output.URISpec = principal.URI.spec;
}
testData.output.originAttributes = principal.originAttributes;
tests.push(testData);
}
return tests;
}
printHistory(); // Copy this into: serializedPrincipalsFromFirefox
*/
let serializedPrincipalsFromFirefox = [
{
input: "SmIS26zLEdO3ZQBgsLbOywAAAAAAAAAAwAAAAAAAAEY=",
output: {
URI: false,
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input:
"ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAe2h0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTLz91dG1fc291cmNlPXd3dy5tb3ppbGxhLm9yZyZ1dG1fbWVkaXVtPXJlZmVycmFsJnV0bV9jYW1wYWlnbj1uYXYmdXRtX2NvbnRlbnQ9ZGV2ZWxvcGVycwAAAAAAAAAFAAAACAAAABUAAAAA/////wAAAAD/////AAAACAAAABUAAAAdAAAAXgAAAB0AAAAHAAAAHQAAAAcAAAAkAAAAAAAAAAD/////AAAAAP////8AAAAlAAAAVgAAAAD/////AQAAAAAAAAAAAAAAAA==",
output: {
URISpec:
"https://developer.mozilla.org/en-US/?utm_source=www.mozilla.org&utm_medium=referral&utm_campaign=nav&utm_content=developers",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input: "SmIS26zLEdO3ZQBgsLbOywAAAAAAAAAAwAAAAAAAAEY=",
output: {
URI: false,
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
{
input:
"vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezA0NWNhMThkLTQzNmMtNDc0NC1iYmI2LWIxYTE1MzY2ZGY3OX0AAAAA",
output: {
URISpec: "moz-nullprincipal:{045ca18d-436c-4744-bbb6-b1a15366df79}",
originAttributes: {
firstPartyDomain: "",
inIsolatedMozBrowser: false,
privateBrowsingId: 0,
userContextId: 0,
geckoViewSessionContextId: "",
partitionKey: "",
},
},
},
];
for (let test of serializedPrincipalsFromFirefox) {
let principal = E10SUtils.deserializePrincipal(test.input);
for (let key in principal.originAttributes) {
is(
principal.originAttributes[key],
test.output.originAttributes[key],
`Ensure value of ${key} is ${test.output.originAttributes[key]}`
);
}
if ("URI" in test.output && test.output.URI === false) {
is(
principal.isContentPrincipal,
false,
"Should have not have a URI for system"
);
} else {
is(
principal.spec,
test.output.URISpec,
`Should have spec ${test.output.URISpec}`
);
}
}
});

View File

@@ -0,0 +1,11 @@
[DEFAULT]
prefs = [
"zen.window-sync.enabled=false",
]
["browser_gesture_navigation.js"]
["browser_gesture_scroll.js"]
skip-if = [
"os == 'mac' && os_version == '15.30' && arch == 'aarch64'", # Bug 1988390
]

View File

@@ -0,0 +1,231 @@
"use strict";
add_setup(async () => {
// Disable window occlusion. See bug 1733955 / bug 1779559.
if (navigator.platform.indexOf("Win") == 0) {
await SpecialPowers.pushPrefEnv({
set: [["widget.windows.window_occlusion_tracking.enabled", false]],
});
}
});
add_task(async () => {
// Open a new browser window to make sure there is no navigation history.
const newBrowser = await BrowserTestUtils.openNewBrowserWindow({});
let event = {
direction: SimpleGestureEvent.DIRECTION_LEFT,
};
ok(!newBrowser.gGestureSupport._shouldDoSwipeGesture(event));
event = {
direction: SimpleGestureEvent.DIRECTION_RIGHT,
};
ok(!newBrowser.gGestureSupport._shouldDoSwipeGesture(event));
await BrowserTestUtils.closeWindow(newBrowser);
});
function createSimpleGestureEvent(type, direction) {
let event = document.createEvent("SimpleGestureEvent");
event.initSimpleGestureEvent(
type,
false /* canBubble */,
false /* cancelableArg */,
window,
0 /* detail */,
0 /* screenX */,
0 /* screenY */,
0 /* clientX */,
0 /* clientY */,
false /* ctrlKey */,
false /* altKey */,
false /* shiftKey */,
false /* metaKey */,
0 /* button */,
null /* relatedTarget */,
0 /* allowedDirections */,
direction,
1 /* delta */
);
return event;
}
add_task(async () => {
await SpecialPowers.pushPrefEnv({
set: [["ui.swipeAnimationEnabled", false]],
});
// Open a new browser window and load two pages so that the browser can go
// back but can't go forward.
const newWindow = await BrowserTestUtils.openNewBrowserWindow({});
// gHistroySwipeAnimation gets initialized in a requestIdleCallback so we need
// to wait for the initialization.
await TestUtils.waitForCondition(() => {
return (
// There's no explicit notification for the initialization, so we wait
// until `isLTR` matches the browser locale state.
newWindow.gHistorySwipeAnimation.isLTR != Services.locale.isAppLocaleRTL
);
});
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:mozilla"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:mozilla"
);
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:about"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:about"
);
let event = createSimpleGestureEvent(
"MozSwipeGestureMayStart",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport._shouldDoSwipeGesture(event);
// Assuming we are on LTR environment.
is(
event.allowedDirections,
SimpleGestureEvent.DIRECTION_LEFT,
"Allows only swiping to left, i.e. backward"
);
event = createSimpleGestureEvent(
"MozSwipeGestureMayStart",
SimpleGestureEvent.DIRECTION_RIGHT
);
newWindow.gGestureSupport._shouldDoSwipeGesture(event);
is(
event.allowedDirections,
SimpleGestureEvent.DIRECTION_LEFT,
"Allows only swiping to left, i.e. backward"
);
await BrowserTestUtils.closeWindow(newWindow);
});
add_task(async () => {
await SpecialPowers.pushPrefEnv({
set: [["ui.swipeAnimationEnabled", true]],
});
// Open a new browser window and load two pages so that the browser can go
// back but can't go forward.
const newWindow = await BrowserTestUtils.openNewBrowserWindow({});
if (!newWindow.gHistorySwipeAnimation._isSupported()) {
await BrowserTestUtils.closeWindow(newWindow);
return;
}
function sendSwipeSequence(sendEnd) {
let event = createSimpleGestureEvent(
"MozSwipeGestureMayStart",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
event = createSimpleGestureEvent(
"MozSwipeGestureStart",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
event = createSimpleGestureEvent(
"MozSwipeGestureUpdate",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
event = createSimpleGestureEvent(
"MozSwipeGestureUpdate",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
if (sendEnd) {
sendSwipeEnd();
}
}
function sendSwipeEnd() {
let event = createSimpleGestureEvent(
"MozSwipeGestureEnd",
SimpleGestureEvent.DIRECTION_LEFT
);
newWindow.gGestureSupport.handleEvent(event);
}
// gHistroySwipeAnimation gets initialized in a requestIdleCallback so we need
// to wait for the initialization.
await TestUtils.waitForCondition(() => {
return (
// There's no explicit notification for the initialization, so we wait
// until `isLTR` matches the browser locale state.
newWindow.gHistorySwipeAnimation.isLTR != Services.locale.isAppLocaleRTL
);
});
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:mozilla"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:mozilla"
);
BrowserTestUtils.startLoadingURIString(
newWindow.gBrowser.selectedBrowser,
"about:about"
);
await BrowserTestUtils.browserLoaded(
newWindow.gBrowser.selectedBrowser,
false,
"about:about"
);
// Start a swipe that's not enough to navigate
sendSwipeSequence(/* sendEnd = */ true);
// Wait two frames
await new Promise(r =>
window.requestAnimationFrame(() => window.requestAnimationFrame(r))
);
// The transition to fully stopped shouldn't have had enough time yet to
// become fully stopped.
ok(
newWindow.gHistorySwipeAnimation._isStoppingAnimation,
"should be stopping anim"
);
// Start another swipe.
sendSwipeSequence(/* sendEnd = */ false);
// Wait two frames
await new Promise(r =>
window.requestAnimationFrame(() => window.requestAnimationFrame(r))
);
// We should have started a new swipe, ie we shouldn't be stopping.
ok(
!newWindow.gHistorySwipeAnimation._isStoppingAnimation,
"should not be stopping anim"
);
sendSwipeEnd();
await BrowserTestUtils.closeWindow(newWindow);
});

View File

@@ -0,0 +1,43 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const URL = `data:text/html,
<!doctype html>
<meta charset="utf-8">
<div style="height: 200vh"></div>
Some content
<div style="height: 200vh"></div>
`;
function getScrollPos(browser) {
return SpecialPowers.spawn(browser, [], () => {
return {
scrollY: Math.round(content.scrollY),
scrollMaxY: Math.round(content.scrollMaxY),
};
});
}
// Bug 1964901
add_task(async function test_scroll_command() {
await BrowserTestUtils.withNewTab(URL, async function (browser) {
{
let { scrollY, scrollMaxY } = await getScrollPos(browser);
is(scrollY, 0, "Should be scrolled to the top");
isnot(scrollMaxY, 0, "Should be scrollable");
}
let scrollEvent = SpecialPowers.spawn(browser, [], async () => {
await new Promise(r => {
content.addEventListener("scrollend", r, { once: true });
});
});
goDoCommand("cmd_scrollBottom");
await scrollEvent;
{
let { scrollY, scrollMaxY } = await getScrollPos(browser);
is(scrollY, scrollMaxY, "Should be scrolled to the bottom");
}
});
});

View File

@@ -0,0 +1,12 @@
[DEFAULT]
["browser_contentAreaClick_subframe_javascript.js"]
support-files = [
"file_contentAreaClick_subframe_javascript.html"
]
["browser_javascript_links.js"]
disabled="Disabled by import_external_tests.py"
support-files = [
"file_javascript_links_subframe.html"
]

View File

@@ -0,0 +1,49 @@
const gExampleComRoot = getRootDirectory(gTestPath).replace(
"chrome://mochitests/content/",
"https://example.com/"
);
const IFRAME_FILE = "file_contentAreaClick_subframe_javascript.html";
add_task(async function () {
await SpecialPowers.pushPrefEnv({
set: [["browser.link.alternative_click.block_javascript", false]],
});
await BrowserTestUtils.withNewTab(
`data:text/html,<iframe src="${gExampleComRoot + IFRAME_FILE}"></iframe>`,
async browser => {
let newTabPromise = BrowserTestUtils.waitForNewTab(
gBrowser,
"about:blank"
);
let javascriptRanPromise = TestUtils.topicObserved(
"contentAreaClick-javascriptRan"
);
// ctrl/cmd-click the link in the subframe. This should cause it to be
// loaded in a new tab.
info("Clicking link");
let expectedRemoteType =
browser.browsingContext.children[0].currentRemoteType;
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ ctrlKey: true, metaKey: true },
browser.browsingContext.children[0]
);
info("Waiting for new tab");
let newTab = await newTabPromise;
info("Waiting to be notified that the javascript ran");
await javascriptRanPromise;
is(
newTab.linkedBrowser.remoteType,
expectedRemoteType,
"new tab was loaded in expected process"
);
info("Removing the tab");
BrowserTestUtils.removeTab(newTab);
}
);
});

View File

@@ -0,0 +1,88 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const TEST_PATH = getRootDirectory(gTestPath).replace(
"chrome://mochitests/content/",
"https://example.com/"
);
const IFRAME_PATH = TEST_PATH + "file_javascript_links_subframe.html";
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
set: [
["browser.link.alternative_click.block_javascript", true],
["browser.tabs.opentabfor.middleclick", true],
["middlemouse.paste", false],
["middlemouse.contentLoadURL", false],
["general.autoScroll", false],
],
});
});
add_task(async function () {
await BrowserTestUtils.withNewTab(
`data:text/html,<a href="javascript:alert(1);">click me`,
async browser => {
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ button: 0, ctrlKey: true, metaKey: true },
browser
);
is(
gBrowser.tabs.length,
2,
"Accel+click on javascript: link shouldn't open a new tab"
);
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ button: 1 },
browser
);
is(
gBrowser.tabs.length,
2,
"Middle click on javascript: link shouldn't open a new tab"
);
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ button: 0, shiftKey: true },
browser
);
// This is fragile and might miss the new window, but the test will fail
// anyway when finishing with an extra window left behind.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(resolve => setTimeout(resolve, 200));
is(
BrowserWindowTracker.windowCount,
1,
"Shift+click on javascript: link shouldn't open a new window"
);
}
);
});
add_task(async function iframe_link() {
await BrowserTestUtils.withNewTab(
`data:text/html,<iframe src="${IFRAME_PATH}"></iframe>`,
async browser => {
// ctrl/cmd-click the link in the subframe.
await BrowserTestUtils.synthesizeMouseAtCenter(
"a",
{ ctrlKey: true, metaKey: true },
browser.browsingContext.children[0]
);
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(resolve => setTimeout(resolve, 200));
is(
gBrowser.tabs.length,
2,
"Click on javascript: link in iframe shouldn't open a new tab"
);
}
);
});

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
<a href="javascript:SpecialPowers.notifyObserversInParentProcess(null, 'contentAreaClick-javascriptRan');">link</a>

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
<a href="javascript:alert(1)">click me

View File

@@ -8,11 +8,21 @@
BROWSER_CHROME_MANIFESTS += [ BROWSER_CHROME_MANIFESTS += [
"alerts/browser.toml",
"backforward/browser.toml",
"caps/browser.toml",
"gesture/browser.toml",
"linkHandling/browser.toml",
"readermode/browser.toml", "readermode/browser.toml",
"safebrowsing/browser.toml", "safebrowsing/browser.toml",
"sessionstore/browser.toml",
"shell/browser.toml", "shell/browser.toml",
"tabMediaIndicator/browser.toml", "tabMediaIndicator/browser.toml",
"tooltiptext/browser.toml", "tooltiptext/browser.toml",
"zoom/browser.toml",
] ]
XPCSHELL_TESTS_MANIFESTS += [ XPCSHELL_TESTS_MANIFESTS += [
"remote-settings/xpcshell.toml",
"services-crypto/xpcshell.toml",
"sync/xpcshell.toml",
] ]

View File

@@ -0,0 +1,86 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/* import-globals-from ../../../common/tests/unit/head_global.js */
/* import-globals-from ../../../common/tests/unit/head_helpers.js */
"use strict";
ChromeUtils.defineESModuleGetters(this, {
AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
Database: "resource://services-settings/Database.sys.mjs",
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
RemoteSettingsClient:
"resource://services-settings/RemoteSettingsClient.sys.mjs",
RemoteSettingsWorker:
"resource://services-settings/RemoteSettingsWorker.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
SharedUtils: "resource://services-settings/SharedUtils.sys.mjs",
SyncHistory: "resource://services-settings/SyncHistory.sys.mjs",
TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
TestUtils: "resource://testing-common/TestUtils.sys.mjs",
UptakeTelemetry: "resource://services-settings/UptakeTelemetry.sys.mjs",
Utils: "resource://services-settings/Utils.sys.mjs",
});
const IS_ANDROID = AppConstants.platform == "android";
function arrayEqual(a, b) {
return JSON.stringify(a) == JSON.stringify(b);
}
add_setup(function () {
Services.fog.initializeFOG();
});
function enableUptakeMetric() {
Services.fog.applyServerKnobsConfig(
JSON.stringify({
metrics_enabled: {
"uptake.remotecontent.result.uptake_remotesettings": true,
},
})
);
}
function assertTelemetryEvents(expectedEvents) {
if (IS_ANDROID) {
/*
Since `Services.fog.applyServerKnobsConfig()` has no effect on Android
https://searchfox.org/firefox-main/rev/e28b34ab33dbf49364999070168cbb7e11e8e5bd/toolkit/components/glean/xpcom/FOG.cpp#379-390
we skip assertions on events on this platform.
Alternatively we could have disabled the whole tests suites or introduced `if IS_ANDROID` on each
test involving telemetry, but this seems the most reasonable way.
See Bug 2028765 and Bug 2042499
*/
Assert.ok(
true,
"Glean Uptake Telemetry assertions are ALWAYS true on Android"
);
return;
}
const events =
Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue() ?? [];
const receivedValues = events.map(e => e.extra.value);
Assert.equal(
events.length,
expectedEvents.length,
`number of uptake events (${receivedValues})`
);
for (let i = 0; i < expectedEvents.length; i++) {
for (const [key, expected] of Object.entries(expectedEvents[i])) {
if (typeof expected === "function") {
Assert.ok(
expected(events[i].extra[key]),
`event[${i}].extra.${key} passes validator`
);
} else {
Assert.equal(
events[i].extra[key],
expected,
`event[${i}].extra.${key}`
);
}
}
}
}

View File

@@ -0,0 +1,896 @@
const { Downloader } = ChromeUtils.importESModule(
"resource://services-settings/Attachments.sys.mjs"
);
const RECORD = {
id: "1f3a0802-648d-11ea-bd79-876a8b69c377",
attachment: {
hash: "f41ed47d0f43325c9f089d03415c972ce1d3f1ecab6e4d6260665baf3db3ccee",
size: 1597,
filename: "test_file.pem",
location:
"main-workspace/some-collection/65650a0f-7c22-4c10-9744-2d67e301f5f4.pem",
mimetype: "application/x-pem-file",
},
};
const RECORD_OF_DUMP = {
id: "filename-of-dump.txt",
attachment: {
filename: "filename-of-dump.txt",
hash: "4c46ef7e4f1951d210fe54c21e07c09bab265fd122580083ed1d6121547a8c6b",
size: 25,
},
last_modified: 1234567,
some_key: "some metadata",
};
let downloader;
let server;
add_setup(() => {
server = new HttpServer();
server.start(-1);
registerCleanupFunction(() => server.stop(() => {}));
server.registerDirectory(
"/cdn/main-workspace/some-collection/",
do_get_file("test_attachments_downloader")
);
server.registerDirectory(
"/cdn/bundles/",
do_get_file("test_attachments_downloader")
);
// For this test, we are using a server other than production. Force
// LOAD_DUMPS to true so that we can still load attachments from dumps.
delete Utils.LOAD_DUMPS;
Utils.LOAD_DUMPS = true;
});
async function clear_state() {
Services.fog.testResetFOG();
enableUptakeMetric();
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v1`
);
downloader = new Downloader("main", "some-collection");
downloader.cache = {};
const memCacheImpl = {
get: async id => {
return downloader.cache[id];
},
set: async (id, obj) => {
downloader.cache[id] = obj;
},
setMultiple: async idsObjs => {
idsObjs.forEach(([id, obj]) => (downloader.cache[id] = obj));
},
delete: async id => {
delete downloader.cache[id];
},
deleteMultiple: async ids => {
ids.forEach(id => delete downloader.cache[id]);
},
hasData: async () => {
return !!Object.keys(downloader.cache).length;
},
};
// The download() method requires a cacheImpl, but the Downloader
// class does not have one. Define a dummy no-op one.
Object.defineProperty(downloader, "cacheImpl", {
value: memCacheImpl,
// Writable to allow specific tests to override cacheImpl.
writable: true,
});
await downloader.deleteDownloaded(RECORD);
server.registerPathHandler("/v1/", (request, response) => {
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: `http://localhost:${server.identity.primaryPort}/cdn/`,
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
// For tests that use a real client and DB cache, clear the local DB too.
const client = RemoteSettings("some-collection");
await client.db.clear();
await client.db.pruneAttachments([]);
}
add_task(clear_state);
add_task(
async function test_download_throws_server_info_error_if_invalid_response() {
server.registerPathHandler("/v1/", (request, response) => {
response.write("{bad json content");
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
let error;
try {
await downloader.download(RECORD);
} catch (e) {
error = e;
}
Assert.ok(error instanceof Downloader.ServerInfoError);
}
);
add_task(clear_state);
add_task(async function test_download_is_retried_3_times_if_download_fails() {
const record = {
id: "abc",
attachment: {
...RECORD.attachment,
location: "404-error.pem",
},
};
let called = 0;
const _fetchAttachment = downloader._fetchAttachment;
downloader._fetchAttachment = async url => {
called++;
return _fetchAttachment(url);
};
let error;
try {
await downloader.download(record);
} catch (e) {
error = e;
}
Assert.equal(called, 4); // 1 + 3 retries
Assert.ok(error instanceof Downloader.DownloadError);
});
add_task(clear_state);
add_task(async function test_download_as_bytes() {
const bytes = await downloader.downloadAsBytes(RECORD);
// See *.pem file in tests data.
Assert.greater(
bytes.byteLength,
1500,
`Wrong bytes size: ${bytes.byteLength}`
);
});
add_task(clear_state);
add_task(async function test_download_is_retried_3_times_if_content_fails() {
const record = {
id: "abc",
attachment: {
...RECORD.attachment,
hash: "always-wrong",
},
};
let called = 0;
downloader._fetchAttachment = async () => {
called++;
return new ArrayBuffer();
};
let error;
try {
await downloader.download(record);
} catch (e) {
error = e;
}
Assert.equal(called, 4); // 1 + 3 retries
Assert.ok(error instanceof Downloader.BadContentError);
});
add_task(clear_state);
add_task(async function test_delete_all() {
const client = RemoteSettings("some-collection");
await client.db.create(RECORD);
await downloader.download(RECORD);
await client.attachments.deleteAll();
Assert.ok(!(await client.attachments.cacheImpl.get(RECORD.id)));
});
add_task(clear_state);
add_task(async function test_downloader_reports_download_errors() {
const client = RemoteSettings("some-collection");
const record = {
attachment: {
...RECORD.attachment,
location: "404-error.pem",
},
};
try {
await client.attachments.download(record, { retry: 0 });
} catch (e) {}
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.DOWNLOAD_START,
source: client.identifier,
},
{
value: UptakeTelemetry.STATUS.DOWNLOAD_ERROR,
source: client.identifier,
},
]);
});
add_task(clear_state);
add_task(async function test_downloader_reports_offline_error() {
const backupOffline = Services.io.offline;
Services.io.offline = true;
try {
const client = RemoteSettings("some-collection");
const record = {
attachment: {
...RECORD.attachment,
location: "will-try-and-fail.pem",
},
};
try {
await client.attachments.download(record, { retry: 0 });
} catch (e) {}
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.DOWNLOAD_START,
source: client.identifier,
},
{
value: UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR,
source: client.identifier,
},
]);
} finally {
Services.io.offline = backupOffline;
}
});
add_task(clear_state);
// Common code for test_download_cache_hit and test_download_cache_corruption.
async function doTestDownloadCacheImpl({
simulateCorruption,
expectedReads = 1,
expectedWrites = 1,
downloadOptions = {},
}) {
let readCount = 0;
let writeCount = 0;
const cacheImpl = {
async get(attachmentId) {
Assert.equal(attachmentId, RECORD.id, "expected attachmentId");
++readCount;
if (simulateCorruption) {
throw new Error("Simulation of corrupted cache (read)");
}
},
async set(attachmentId, attachment) {
Assert.equal(attachmentId, RECORD.id, "expected attachmentId");
Assert.deepEqual(attachment.record, RECORD, "expected record");
++writeCount;
if (simulateCorruption) {
throw new Error("Simulation of corrupted cache (write)");
}
},
async delete() {},
};
Object.defineProperty(downloader, "cacheImpl", { value: cacheImpl });
let downloadResult = await downloader.download(RECORD, downloadOptions);
Assert.equal(downloadResult._source, "remote_match", "expected source");
Assert.equal(downloadResult.buffer.byteLength, 1597, "expected result");
Assert.equal(readCount, expectedReads, "expected cache read attempts");
Assert.equal(writeCount, expectedWrites, "expected cache write attempts");
}
add_task(async function test_download_cache_hit() {
await doTestDownloadCacheImpl({ simulateCorruption: false });
});
add_task(clear_state);
// Verify that the downloader works despite a broken cache implementation.
add_task(async function test_download_cache_corruption() {
await doTestDownloadCacheImpl({ simulateCorruption: true });
});
add_task(clear_state);
add_task(async function test_download_with_cache_enabled() {
await doTestDownloadCacheImpl({
simulateCorruption: false,
downloadOptions: {
cacheResult: true,
},
});
});
add_task(clear_state);
add_task(async function test_download_with_cache_disabled() {
await doTestDownloadCacheImpl({
simulateCorruption: false,
expectedWrites: 0,
downloadOptions: {
cacheResult: false,
},
});
});
add_task(clear_state);
add_task(async function test_download_cached() {
const client = RemoteSettings("main", "some-collection");
const attachmentId = "dummy filename";
const badRecord = {
attachment: {
...RECORD.attachment,
hash: "non-matching hash",
location: "non-existing-location-should-fail.bin",
},
};
async function downloadWithCache(record, options) {
options = { ...options, useCache: true };
return client.attachments.download(record, options);
}
function checkInfo(downloadResult, expectedSource, msg) {
Assert.deepEqual(
downloadResult.record,
RECORD,
`${msg} : expected identical record`
);
// Simple check: assume that content is identical if the size matches.
Assert.equal(
downloadResult.buffer.byteLength,
RECORD.attachment.size,
`${msg} : expected buffer`
);
Assert.equal(
downloadResult._source,
expectedSource,
`${msg} : expected source of the result`
);
}
await Assert.rejects(
downloadWithCache(null, { attachmentId }),
/DownloadError: Could not download dummy filename/,
"Download without record or cache should fail."
);
// Populate cache.
const info1 = await downloadWithCache(RECORD, { attachmentId });
checkInfo(info1, "remote_match", "first time download");
await Assert.rejects(
downloadWithCache(null, { attachmentId }),
/DownloadError: Could not download dummy filename/,
"Download without record still fails even if there is a cache."
);
await Assert.rejects(
downloadWithCache(badRecord, { attachmentId }),
/DownloadError: Could not download .*non-existing-location-should-fail.bin/,
"Download with non-matching record still fails even if there is a cache."
);
// Download from cache.
const info2 = await downloadWithCache(RECORD, { attachmentId });
checkInfo(info2, "cache_match", "download matching record from cache");
const info3 = await downloadWithCache(RECORD, {
attachmentId,
fallbackToCache: true,
});
checkInfo(info3, "cache_match", "fallbackToCache accepts matching record");
const info4 = await downloadWithCache(null, {
attachmentId,
fallbackToCache: true,
});
checkInfo(info4, "cache_fallback", "fallbackToCache accepts null record");
const info5 = await downloadWithCache(badRecord, {
attachmentId,
fallbackToCache: true,
});
checkInfo(info5, "cache_fallback", "fallbackToCache ignores bad record");
// Bye bye cache.
await client.attachments.deleteDownloaded({ id: attachmentId });
await Assert.rejects(
downloadWithCache(null, { attachmentId, fallbackToCache: true }),
/DownloadError: Could not download dummy filename/,
"Download without cache should fail again."
);
await Assert.rejects(
downloadWithCache(badRecord, { attachmentId, fallbackToCache: true }),
/DownloadError: Could not download .*non-existing-location-should-fail.bin/,
"Download should fail to fall back to a download of a non-existing record"
);
});
add_task(clear_state);
add_task(async function test_download_from_dump() {
const client = RemoteSettings("dump-collection", {
bucketName: "dump-bucket",
});
// Temporarily replace the resource:-URL with another resource:-URL.
const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
const resProto = Services.io
.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
resProto.setSubstitution(
"rs-downloader-test",
Services.io.newFileURI(do_get_file("test_attachments_downloader"))
);
function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) {
Assert.equal(
new TextDecoder().decode(new Uint8Array(result.buffer)),
"This would be a RS dump.\n",
"expected content from dump"
);
Assert.deepEqual(result.record, expectedRecord, "expected record for dump");
Assert.equal(result._source, expectedSource, "expected source of dump");
}
// If record matches, should happen before network request.
const dump1 = await client.attachments.download(RECORD_OF_DUMP, {
// Note: attachmentId not set, so should fall back to record.id.
fallbackToDump: true,
});
checkInfo(dump1, "dump_match");
// If no record given, should try network first, but then fall back to dump.
const dump2 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToDump: true,
});
checkInfo(dump2, "dump_fallback");
// Fill the cache with the same data as the dump for the next part.
await client.db.saveAttachment(RECORD_OF_DUMP.id, {
record: RECORD_OF_DUMP,
blob: new Blob([dump1.buffer]),
});
// The dump should take precedence over the cache.
const dump3 = await client.attachments.download(RECORD_OF_DUMP, {
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump3, "dump_match");
// When the record is not given, the dump takes precedence over the cache
// as a fallback (when the cache and dump are identical).
const dump4 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump4, "dump_fallback");
// Store a record in the cache that is newer than the dump.
const RECORD_NEWER_THAN_DUMP = {
...RECORD_OF_DUMP,
last_modified: RECORD_OF_DUMP.last_modified + 1,
};
await client.db.saveAttachment(RECORD_OF_DUMP.id, {
record: RECORD_NEWER_THAN_DUMP,
blob: new Blob([dump1.buffer]),
});
// When the record is not given, use the cache if it has a more recent record.
const dump5 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump5, "cache_fallback", RECORD_NEWER_THAN_DUMP);
// When a record is given, use whichever that has the matching last_modified.
const dump6 = await client.attachments.download(RECORD_OF_DUMP, {
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump6, "dump_match");
const dump7 = await client.attachments.download(RECORD_NEWER_THAN_DUMP, {
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump7, "cache_match", RECORD_NEWER_THAN_DUMP);
await client.attachments.deleteDownloaded(RECORD_OF_DUMP);
await Assert.rejects(
client.attachments.download(null, {
attachmentId: "filename-without-meta.txt",
fallbackToDump: true,
}),
/DownloadError: Could not download filename-without-meta.txt/,
"Cannot download dump that lacks a .meta.json file"
);
await Assert.rejects(
client.attachments.download(null, {
attachmentId: "filename-without-content.txt",
fallbackToDump: true,
}),
/Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/,
"Cannot download dump that is missing, despite the existing .meta.json"
);
// Restore, just in case.
Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
resProto.setSubstitution("rs-downloader-test", null);
});
// Not really needed because the last test doesn't modify the main collection,
// but added for consistency with other tests tasks around here.
add_task(clear_state);
add_task(
async function test_download_from_dump_fails_when_load_dumps_is_false() {
const client = RemoteSettings("dump-collection", {
bucketName: "dump-bucket",
});
// Temporarily replace the resource:-URL with another resource:-URL.
const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
const resProto = Services.io
.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
resProto.setSubstitution(
"rs-downloader-test",
Services.io.newFileURI(do_get_file("test_attachments_downloader"))
);
function checkInfo(
result,
expectedSource,
expectedRecord = RECORD_OF_DUMP
) {
Assert.equal(
new TextDecoder().decode(new Uint8Array(result.buffer)),
"This would be a RS dump.\n",
"expected content from dump"
);
Assert.deepEqual(
result.record,
expectedRecord,
"expected record for dump"
);
Assert.equal(result._source, expectedSource, "expected source of dump");
}
// Download the dump so that we can use it to fill the cache.
const dump1 = await client.attachments.download(RECORD_OF_DUMP, {
// Note: attachmentId not set, so should fall back to record.id.
fallbackToDump: true,
});
checkInfo(dump1, "dump_match");
// Fill the cache with the same data as the dump for the next part.
await client.db.saveAttachment(RECORD_OF_DUMP.id, {
record: RECORD_OF_DUMP,
blob: new Blob([dump1.buffer]),
});
// Now turn off loading dumps, and check we no longer load from the dump,
// but use the cache instead.
Utils.LOAD_DUMPS = false;
const dump2 = await client.attachments.download(RECORD_OF_DUMP, {
// Note: attachmentId not set, so should fall back to record.id.
fallbackToDump: true,
});
checkInfo(dump2, "cache_match");
// When the record is not given, the dump would take precedence over the
// cache but we have disabled dumps, so we should load from the cache.
const dump4 = await client.attachments.download(null, {
attachmentId: RECORD_OF_DUMP.id,
fallbackToCache: true,
fallbackToDump: true,
});
checkInfo(dump4, "cache_fallback");
// Restore, just in case.
Utils.LOAD_DUMPS = true;
Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
resProto.setSubstitution("rs-downloader-test", null);
}
);
add_task(async function test_attachment_get() {
// Since get() is largely a wrapper around the same code as download(),
// we only test a couple of parts to check it functions as expected, and
// rely on the download() testing for the rest.
await Assert.rejects(
downloader.get(RECORD),
/NotFoundError: Could not find /,
"get() fails when there is no local cache nor dump"
);
const client = RemoteSettings("dump-collection", {
bucketName: "dump-bucket",
});
// Temporarily replace the resource:-URL with another resource:-URL.
const orig_RESOURCE_BASE_URL = Downloader._RESOURCE_BASE_URL;
Downloader._RESOURCE_BASE_URL = "resource://rs-downloader-test";
const resProto = Services.io
.getProtocolHandler("resource")
.QueryInterface(Ci.nsIResProtocolHandler);
resProto.setSubstitution(
"rs-downloader-test",
Services.io.newFileURI(do_get_file("test_attachments_downloader"))
);
function checkInfo(result, expectedSource, expectedRecord = RECORD_OF_DUMP) {
Assert.equal(
new TextDecoder().decode(new Uint8Array(result.buffer)),
"This would be a RS dump.\n",
"expected content from dump"
);
Assert.deepEqual(result.record, expectedRecord, "expected record for dump");
Assert.equal(result._source, expectedSource, "expected source of dump");
}
// When a record is given, use whichever that has the matching last_modified.
const dump = await client.attachments.get(RECORD_OF_DUMP);
checkInfo(dump, "dump_match");
await client.attachments.deleteDownloaded(RECORD_OF_DUMP);
await Assert.rejects(
client.attachments.get(null, {
attachmentId: "filename-without-meta.txt",
fallbackToDump: true,
}),
/NotFoundError: Could not find filename-without-meta.txt in cache or dump/,
"Cannot download dump that lacks a .meta.json file"
);
await Assert.rejects(
client.attachments.get(null, {
attachmentId: "filename-without-content.txt",
fallbackToDump: true,
}),
/Could not download resource:\/\/rs-downloader-test\/settings\/dump-bucket\/dump-collection\/filename-without-content\.txt(?!\.meta\.json)/,
"Cannot download dump that is missing, despite the existing .meta.json"
);
// Restore, just in case.
Downloader._RESOURCE_BASE_URL = orig_RESOURCE_BASE_URL;
resProto.setSubstitution("rs-downloader-test", null);
});
// Not really needed because the last test doesn't modify the main collection,
// but added for consistency with other tests tasks around here.
add_task(clear_state);
add_task(async function test_obsolete_attachments_are_pruned() {
const RECORD2 = {
...RECORD,
id: "another-id",
};
const client = RemoteSettings("some-collection");
// Store records and related attachments directly in the cache.
await client.db.importChanges({}, 42, [RECORD, RECORD2], { clear: true });
await client.db.saveAttachment(RECORD.id, {
record: RECORD,
blob: new Blob(["123"]),
});
await client.db.saveAttachment("custom-id", {
record: RECORD2,
blob: new Blob(["456"]),
});
// Store an extraneous cached attachment.
await client.db.saveAttachment("bar", {
record: { id: "bar" },
blob: new Blob(["789"]),
});
const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id);
Assert.equal(
await recordAttachment.blob.text(),
"123",
"Record has a cached attachment"
);
const record2Attachment = await client.attachments.cacheImpl.get("custom-id");
Assert.equal(
await record2Attachment.blob.text(),
"456",
"Record 2 has a cached attachment"
);
const { blob: cachedExtra } = await client.attachments.cacheImpl.get("bar");
Assert.equal(await cachedExtra.text(), "789", "There is an extra attachment");
await client.attachments.prune([]);
Assert.ok(
await client.attachments.cacheImpl.get(RECORD.id),
"Record attachment was kept"
);
Assert.ok(
await client.attachments.cacheImpl.get("custom-id"),
"Record 2 attachment was kept"
);
Assert.ok(
!(await client.attachments.cacheImpl.get("bar")),
"Extra was deleted"
);
});
add_task(clear_state);
add_task(
async function test_obsolete_attachments_listed_as_excluded_are_not_pruned() {
const client = RemoteSettings("some-collection");
// Store records and related attachments directly in the cache.
await client.db.importChanges({}, 42, [], { clear: true });
await client.db.saveAttachment(RECORD.id, {
record: RECORD,
blob: new Blob(["123"]),
});
const recordAttachment = await client.attachments.cacheImpl.get(RECORD.id);
Assert.equal(
await recordAttachment.blob.text(),
"123",
"Record has a cached attachment"
);
await client.attachments.prune([RECORD.id]);
Assert.ok(
await client.attachments.cacheImpl.get(RECORD.id),
"Record attachment was kept"
);
}
);
add_task(clear_state);
add_task(async function test_cacheAll_happy_path() {
// verify bundle is downloaded succesfully
const allSuccess = await downloader.cacheAll();
Assert.ok(
allSuccess,
"Attachments cacheAll succesfully downloaded a bundle and saved all attachments"
);
// verify accuracy of attachments downloaded
Assert.equal(
downloader.cache["1"].record.title,
"test1",
"Test record 1 meta content appears accurate."
);
Assert.equal(
await downloader.cache["1"].blob.text(),
"test1\n",
"Test file 1 content is accurate."
);
Assert.equal(
downloader.cache["2"].record.title,
"test2",
"Test record 2 meta content appears accurate."
);
Assert.equal(
await downloader.cache["2"].blob.text(),
"test2\n",
"Test file 2 content is accurate."
);
});
add_task(async function test_cacheAll_using_real_db() {
const client = RemoteSettings("some-collection");
const allSuccess = await client.attachments.cacheAll();
Assert.ok(
allSuccess,
"Attachments cacheAll succesfully downloaded a bundle and saved all attachments"
);
Assert.equal(
(await client.attachments.cacheImpl.get("2")).record.title,
"test2",
"Test record 2 meta content appears accurate."
);
Assert.equal(
await (await client.attachments.cacheImpl.get("2")).blob.text(),
"test2\n",
"Test file 2 content is accurate."
);
});
add_task(clear_state);
add_task(async function test_cacheAll_skips_with_existing_data() {
downloader.cache = {
1: "1",
};
const allSuccess = await downloader.cacheAll();
Assert.equal(
allSuccess,
null,
"Attachments cacheAll skips downloads if data already exists"
);
});
add_task(async function test_cacheAll_does_not_skip_if_force_is_true() {
downloader.cache = {
1: "1",
};
const allSuccess = await downloader.cacheAll(true);
Assert.equal(
allSuccess,
true,
"Attachments cacheAll does not skip downloads if force is true"
);
});
add_task(clear_state);
add_task(async function test_cacheAll_failed_request() {
downloader.bucketName = "fake-bucket";
downloader.collectionName = "fake-collection";
const allSuccess = await downloader.cacheAll();
Assert.equal(
allSuccess,
false,
"Attachments cacheAll request failed to download a bundle and returned false"
);
});
add_task(clear_state);
add_task(async function test_cacheAll_failed_unzip() {
downloader.bucketName = "error-bucket";
downloader.collectionName = "bad-zip";
const allSuccess = await downloader.cacheAll();
Assert.equal(
allSuccess,
false,
"Attachments cacheAll request failed to extract a bundle and returned false"
);
});
add_task(clear_state);
add_task(async function test_cacheAll_failed_save() {
const client = RemoteSettings("some-collection");
const backup = client.db.saveAttachments;
client.db.saveAttachments = () => {
throw new Error("boom");
};
const allSuccess = await client.attachments.cacheAll();
Assert.equal(
allSuccess,
false,
"Attachments cacheAll failed to save entries in DB and returned false"
);
client.db.saveAttachments = backup;
});
add_task(clear_state);

View File

@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEbjCCA1agAwIBAgIQBg3WwdBnkBtUdfz/wp4xNzANBgkqhkiG9w0BAQsFADBa
MQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJl
clRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTE1
MTAxNDEyMDAwMFoXDTIwMTAxNDEyMDAwMFowbzELMAkGA1UEBhMCVVMxCzAJBgNV
BAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRkwFwYDVQQKExBDbG91ZEZs
YXJlLCBJbmMuMSAwHgYDVQQDExdDbG91ZEZsYXJlIEluYyBSU0EgQ0EtMTCCASIw
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJGiNOIE4s0M4wdhDeV9aMfAYY9l
yG9cfGQqt7a5UgrRA81bi4istCyhzfzRWUW+NAmf6X2HEnA3xLI1M+pH/xEbk9pw
jc8/1CPy9jUjBwb89zt5PWh2I1KxZVg/Bnx2yYdVcKTUMKt0GLDXfZXN+RYZHJQo
lDlzjH5xV0IpDMv/FsMEZWcfx1JorBf08bRnRVkl9RY00y2ujVr+492ze+zYQ9s7
HcidpR+7ret3jzLSvojsaA5+fOaCG0ctVJcLfnkQ5lWR95ByBdO1NapfqZ1+kmCL
3baVSeUpYQriBwznxfLuGs8POo4QdviYVtSPBWjOEfb+o1c6Mbo8p4noFzUCAwEA
AaOCARkwggEVMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMDQG
CCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu
Y29tMDoGA1UdHwQzMDEwL6AtoCuGKWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9P
bW5pcm9vdDIwMjUuY3JsMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BTMB0GA1UdDgQWBBSRBYrfTCLG
bYuUTBZFfu5vAvu3wDAfBgNVHSMEGDAWgBTlnVkwgkdYzKz6CFQ2hns6tQRN8DAN
BgkqhkiG9w0BAQsFAAOCAQEAVJle3ar9NSnTrLAhgfkcpClIY6/kabDIEa8cOnu1
SOXf4vbtZakSmmIbFbmYDUGIU5XwwVdF/FKNzNBRf9G4EL/S0NXytBKj4A34UGQA
InaV+DgVLzCifN9cAHi8EFEAfbglUvPvLPFXF0bwffElYm7QBSiHYSZmfOKLCyiv
3zlQsf7ozNBAxfbmnRMRSUBcIhRwnaFoFgDs7yU6R1Yk4pO7eMgWpdPGhymDTIvv
RnauKStzKsAli9i5hQ4nTDITUpMAmeJoXodgwRkC3Civw32UR2rxObIyxPpbfODb
sZKNGO9K5Sjj6turB1zwbd2wI8MhtUCY9tGmSYhe7G6Bkw==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,10 @@
{
"id": "filename-of-dump.txt",
"attachment": {
"filename": "filename-of-dump.txt",
"hash": "4c46ef7e4f1951d210fe54c21e07c09bab265fd122580083ed1d6121547a8c6b",
"size": 25
},
"last_modified": 1234567,
"some_key": "some metadata"
}

View File

@@ -0,0 +1,8 @@
{
"fyi": "This .meta.json file describes an attachment, but that attachment is missing.",
"attachment": {
"filename": "filename-without-content.txt",
"hash": "...",
"size": "..."
}
}

View File

@@ -0,0 +1 @@
The filename-without-meta.txt.meta.json file is missing.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
"use strict";
async function getLocalDumpLastModified(bucket, collection) {
let res;
try {
res = await fetch(
`resource://app/defaults/settings/${bucket}/${collection}.json`
);
} catch (e) {
return -1;
}
const { timestamp } = await res.json();
Assert.greaterOrEqual(
timestamp,
0,
`${bucket}/${collection} dump has timestamp`
);
return timestamp;
}
add_task(async function lastModified_of_non_existing_dump() {
ok(!Utils._dumpStats, "_dumpStats not initialized");
equal(
await Utils.getLocalDumpLastModified("did not", "exist"),
-1,
"A non-existent dump has value -1"
);
ok(Utils._dumpStats, "_dumpStats was initialized");
ok("did not/exist" in Utils._dumpStats, "cached non-existing dump result");
delete Utils._dumpStats["did not/exist"];
});
add_task(async function lastModified_summary_is_correct() {
ok(!!Object.keys(Utils._dumpStats).length, "Contains summary of dumps");
let checked = 0;
for (let [identifier, lastModified] of Object.entries(Utils._dumpStats)) {
let [bucket, collection] = identifier.split("/");
let actual = await getLocalDumpLastModified(bucket, collection);
if (actual < 0) {
info(`${identifier} has no dump, skip.`);
continue;
}
info(`Checking correctness of ${identifier}`);
equal(
await Utils.getLocalDumpLastModified(bucket, collection),
lastModified,
`Expected last_modified value for ${identifier}`
);
equal(lastModified, actual, `last_modified should match collection`);
checked++;
}
Assert.greater(checked, 0, "At least one dump was packaged and checked.");
});

View File

@@ -0,0 +1,210 @@
let client;
async function createRecords(records) {
await client.db.importChanges(
{},
42,
records.map((record, i) => ({
id: `record-${i}`,
...record,
})),
{
clear: true,
}
);
}
add_setup(() => {
client = RemoteSettings("some-key");
});
add_task(async function test_returns_all_without_target() {
await createRecords([
{
passwordSelector: "#pass-signin",
},
{
filter_expression: null,
},
{
filter_expression: "",
},
]);
const list = await client.get();
equal(list.length, 3);
});
add_task(async function test_filters_can_be_disabled() {
const c = RemoteSettings("no-jexl", { filterCreator: null });
await c.db.importChanges({}, 42, [
{
id: "abc",
filter_expression: "1 == 2",
},
]);
const list = await c.get();
equal(list.length, 1);
});
add_task(async function test_returns_entries_where_jexl_is_true() {
await createRecords([
{
willMatch: true,
filter_expression: "1",
},
{
willMatch: true,
filter_expression: "[42]",
},
{
willMatch: true,
filter_expression: "1 == 2 || 1 == 1",
},
{
willMatch: true,
filter_expression: 'env.appinfo.ID == "xpcshell@tests.mozilla.org"',
},
{
willMatch: false,
filter_expression: "env.version == undefined",
},
{
willMatch: true,
filter_expression: "env.unknown == undefined",
},
{
willMatch: false,
filter_expression: "1 == 2",
},
]);
const list = await client.get();
equal(list.length, 5);
ok(list.every(e => e.willMatch));
});
add_task(async function test_ignores_entries_where_jexl_is_invalid() {
await createRecords([
{
filter_expression: "true === true", // JavaScript Error: "Invalid expression token: ="
},
{
filter_expression: "Objects.keys({}) == []", // Token ( (openParen) unexpected in expression
},
]);
const list = await client.get();
equal(list.length, 0);
});
add_task(async function test_support_of_date_filters() {
await createRecords([
{
willMatch: true,
filter_expression: '"1982-05-08"|date < "2016-03-22"|date',
},
{
willMatch: false,
filter_expression: '"2000-01-01"|date < "1970-01-01"|date',
},
]);
const list = await client.get();
equal(list.length, 1);
ok(list.every(e => e.willMatch));
});
add_task(async function test_support_of_preferences_filters() {
await createRecords([
{
willMatch: true,
filter_expression: '"services.settings.last_etag"|preferenceValue == 42',
},
{
willMatch: true,
filter_expression:
'"services.settings.poll_interval"|preferenceExists == true',
},
{
willMatch: true,
filter_expression:
'"services.settings.poll_interval"|preferenceIsUserSet == false',
},
{
willMatch: true,
filter_expression:
'"services.settings.last_etag"|preferenceIsUserSet == true',
},
]);
// Set a pref for the user.
Services.prefs.setIntPref("services.settings.last_etag", 42);
const list = await client.get();
equal(list.length, 4);
ok(list.every(e => e.willMatch));
});
add_task(async function test_support_of_intersect_operator() {
await createRecords([
{
willMatch: true,
filter_expression: '{foo: 1, bar: 2}|keys intersect ["foo"]',
},
{
willMatch: true,
filter_expression: '(["a", "b"] intersect ["a", 1, 4]) == "a"',
},
{
willMatch: false,
filter_expression: '(["a", "b"] intersect [3, 1, 4]) == "c"',
},
{
willMatch: true,
filter_expression: `
[1, 2, 3]
intersect
[3, 4, 5]
`,
},
]);
const list = await client.get();
equal(list.length, 3);
ok(list.every(e => e.willMatch));
});
add_task(async function test_support_of_samples() {
await createRecords([
{
willMatch: true,
filter_expression: '"always-true"|stableSample(1)',
},
{
willMatch: false,
filter_expression: '"always-false"|stableSample(0)',
},
{
willMatch: true,
filter_expression: '"turns-to-true-0"|stableSample(0.5)',
},
{
willMatch: false,
filter_expression: '"turns-to-false-1"|stableSample(0.5)',
},
{
willMatch: true,
filter_expression: '"turns-to-true-0"|bucketSample(0, 50, 100)',
},
{
willMatch: false,
filter_expression: '"turns-to-false-1"|bucketSample(0, 50, 100)',
},
]);
const list = await client.get();
equal(list.length, 3);
ok(list.every(e => e.willMatch));
});

View File

@@ -0,0 +1,139 @@
// A collection with a dump that's packaged on all builds where this test runs,
// including on Android at mobile/android/installer/package-manifest.in
const TEST_BUCKET = "main";
const TEST_COLLECTION = "password-recipes";
let client;
let DUMP_RECORDS;
let DUMP_LAST_MODIFIED;
add_setup(async () => {
// "services.settings.server" pref is not set.
// Test defaults to an unreachable server,
// and will only load from the dump if any.
client = new RemoteSettingsClient(TEST_COLLECTION, {
bucketName: TEST_BUCKET,
});
const dump = await SharedUtils.loadJSONDump(TEST_BUCKET, TEST_COLLECTION);
DUMP_RECORDS = dump.data;
DUMP_LAST_MODIFIED = dump.timestamp;
// Dumps are fetched via the following, which sorts the records, newest first.
// https://searchfox.org/mozilla-central/rev/5b3444ad300e244b5af4214212e22bd9e4b7088a/taskcluster/docker/periodic-updates/scripts/periodic_file_updates.sh#304
equal(
DUMP_LAST_MODIFIED,
DUMP_RECORDS[0].last_modified,
"records in dump ought to be sorted by last_modified"
);
});
async function importData(records) {
await RemoteSettingsWorker._execute("_test_only_import", [
TEST_BUCKET,
TEST_COLLECTION,
records,
records[0]?.last_modified || 0,
]);
}
async function clear_state() {
await client.db.clear();
}
add_task(async function test_load_from_dump_when_offline() {
// Baseline: verify that the collection is empty at first,
// but non-empty after loading from the dump.
const beforeTimestamp = await client.db.getLastModified();
equal(beforeTimestamp, null, "collection empty when offline");
// should import from dump since collection was not initialized.
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "collection loaded from dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_optional_skip_dump_after_empty_import() {
// clear_state should have wiped the database.
const beforeTimestamp = await client.db.getLastModified();
equal(beforeTimestamp, null, "collection empty after clearing");
// Verify that the dump is not imported again by client.get()
// when the database is initialized with an empty dump
// with `loadDumpIfNewer` disabled.
await importData([]); // <-- Empty set of records.
const after = await client.get({ loadDumpIfNewer: false });
equal(after.length, 0, "collection still empty due to import");
equal(await client.getLastModified(), 0, "Empty dump has no timestamp");
});
add_task(clear_state);
add_task(async function test_optional_skip_dump_after_non_empty_import() {
await importData([{ last_modified: 1234, id: "dummy" }]);
const after = await client.get({ loadDumpIfNewer: false });
equal(after.length, 1, "Imported dummy data");
equal(await client.getLastModified(), 1234, "Expected timestamp of import");
await importData([]);
const after2 = await client.get({ loadDumpIfNewer: false });
equal(after2.length, 0, "Previous data wiped on duplicate import");
equal(await client.getLastModified(), 0, "Timestamp of empty collection");
});
add_task(clear_state);
add_task(async function test_load_dump_after_empty_import() {
await importData([]); // <-- Empty set of records, i.e. last_modified = 0.
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "Imported dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_load_dump_after_non_empty_import() {
// Dump is updated regularly, verify that the dump matches our expectations
// before running the test.
Assert.greater(
DUMP_LAST_MODIFIED,
1234,
"Assuming dump to be newer than dummy 1234"
);
await importData([{ last_modified: 1234, id: "dummy" }]);
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "Imported dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_load_dump_after_import_from_broken_distro() {
// Dump is updated regularly, verify that the dump matches our expectations
// before running the test.
Assert.greater(
DUMP_LAST_MODIFIED,
1234,
"Assuming dump to be newer than dummy 1234"
);
// No last_modified time.
await importData([{ id: "dummy" }]);
const after = await client.get();
equal(after.length, DUMP_RECORDS.length, "Imported dump");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "dump's timestamp");
});
add_task(clear_state);
add_task(async function test_skip_dump_if_same_last_modified() {
await importData([{ last_modified: DUMP_LAST_MODIFIED, id: "dummy" }]);
const after = await client.get();
equal(after.length, 1, "Not importing dump when time matches");
equal(await client.getLastModified(), DUMP_LAST_MODIFIED, "Same timestamp");
});
add_task(clear_state);

View File

@@ -0,0 +1,149 @@
const PREF_SETTINGS_SERVER = "services.settings.server";
let server;
let client;
async function clear_state() {
await client.db.clear();
}
function setJSONResponse(response, changeset) {
response.setStatusLine(null, 200, "OK");
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setHeader("Date", new Date().toUTCString());
response.write(JSON.stringify(changeset));
}
add_setup(() => {
Services.prefs.setStringPref("services.settings.loglevel", "debug");
// Set up an HTTP Server
server = new HttpServer();
server.start(-1);
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
client = RemoteSettings("some-cid");
const bodies = {
// First successful sync at timestamp=333
"/v1/buckets/main/collections/some-cid/changeset?_expected=333": {
timestamp: 333,
metadata: {
signatures: [{ signature: "abc", x5u: "data:text/plain;base64,pem" }],
},
changes: [
{ id: "rid2", last_modified: 22 },
{ id: "rid1", last_modified: 11 },
],
},
// Client fetches with _expected=222&_since=333
"/v1/buckets/main/collections/some-cid/changeset?_expected=222&_since=333":
{
timestamp: 222,
metadata: {
signatures: [{ signature: "ghi", x5u: "data:text/plain;base64,pem" }],
},
changes: [{ id: "rid1", last_modified: 11 }],
},
// Client refetches full collection with _expected=222 after signature fails.
"/v1/buckets/main/collections/some-cid/changeset?_expected=222": {
timestamp: 222,
metadata: {
signatures: [{ signature: "ghi", x5u: "data:text/plain;base64,pem" }],
},
changes: [{ id: "rid1", last_modified: 11 }],
},
};
const handler = (request, response) => {
const body = bodies[`${request.path}?${request.queryString}`];
if (!body) {
const err = new Error(
`Unexpected request ${request.path}?${request.queryString}`
);
console.error(err);
throw err;
}
setJSONResponse(response, body);
};
Object.keys(bodies).map(path =>
server.registerPathHandler(path.split("?")[0], handler)
);
// In this test suite, the only valid data is the one at timestamp=222 or timestamp=333, but not a mix of the two.
let backup = client._verifier;
client._verifier = {
asyncVerifyContentSignature: serialized => {
return (
serialized ==
'{"data":[{"id":"rid2","last_modified":22}],"last_modified":"444"}' ||
serialized ==
'{"data":[{"id":"rid1","last_modified":11},{"id":"rid2","last_modified":22}],"last_modified":"333"}' ||
serialized ==
'{"data":[{"id":"rid1","last_modified":11}],"last_modified":"222"}'
);
},
};
registerCleanupFunction(() => {
client._verifier = backup;
server.stop(() => {});
Services.prefs.clearUserPref(PREF_SETTINGS_SERVER);
Services.prefs.clearUserPref("services.settings.loglevel");
});
});
add_task(clear_state);
add_task(async function test_ignores_if_local_signature_is_valid() {
await client.maybeSync(333);
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[333, ["rid1", "rid2"]]
);
await client.maybeSync(222);
// The client should detect that timestamp is older,
// but it ignores it because local data is good.
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[333, ["rid1", "rid2"]]
);
});
add_task(clear_state);
add_task(async function test_uses_old_data_if_local_signature_is_invalid() {
// Import some data in the local DB. Signature will be bad.
await client.db.importChanges({}, 333, [{ id: "myid", last_modified: 1234 }]);
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[333, ["myid"]]
);
await client.maybeSync(222);
// The client should detect that timestamp is older,
// but will replace local data with this old data.
Assert.deepEqual(
[
await client.db.getLastModified(),
(await client.get()).map(({ id }) => id),
],
[222, ["rid1"]]
);
});
add_task(clear_state);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
const PREF_SETTINGS_SERVER = "services.settings.server";
const CHANGES_PATH = "/v1" + Utils.CHANGES_PATH;
const BROKEN_SYNC_THRESHOLD = 10; // See default pref value
let server;
let client;
let maybeSyncBackup;
async function clear_state() {
// Disable logging output.
Services.prefs.setStringPref("services.settings.loglevel", "critical");
// Pull data from the test server.
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
// Clear sync history.
await new SyncHistory("").clear();
// Simulate a response whose ETag gets incremented on each call
// (in order to generate several history entries, indexed by timestamp).
let timestamp = 1337;
server.registerPathHandler(CHANGES_PATH, (request, response) => {
response.setStatusLine(null, 200, "OK");
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setHeader("Date", new Date(1000000).toUTCString());
response.setHeader("ETag", `"${timestamp}"`);
response.write(
JSON.stringify({
timestamp,
changes: [
{
last_modified: ++timestamp,
bucket: "main",
collection: "desktop-manager",
},
],
})
);
});
// Restore original maybeSync() method between each test.
client.maybeSync = maybeSyncBackup;
}
function run_test() {
// Set up an HTTP Server
server = new HttpServer();
server.start(-1);
client = RemoteSettings("desktop-manager");
maybeSyncBackup = client.maybeSync;
run_next_test();
registerCleanupFunction(() => {
server.stop(() => {});
// Restore original maybeSync() method when test suite is done.
client.maybeSync = maybeSyncBackup;
});
}
add_task(clear_state);
add_task(async function test_db_is_destroyed_when_sync_is_broken() {
// Simulate a successful sync.
client.maybeSync = async () => {
// Store some data in local DB.
await client.db.importChanges({}, 1515, []);
};
await RemoteSettings.pollChanges({ trigger: "timer" });
// Register a client with a failing sync method.
client.maybeSync = () => {
throw new RemoteSettingsClient.InvalidSignatureError(
"main/desktop-manager"
);
};
// Now obtain several failures in a row.
for (var i = 0; i < BROKEN_SYNC_THRESHOLD; i++) {
try {
await RemoteSettings.pollChanges({ trigger: "timer" });
} catch (e) {}
}
// Synchronization is in broken state.
Assert.equal(
await client.db.getLastModified(),
1515,
"Local DB was not destroyed yet"
);
// Synchronize again. Broken state will be detected.
try {
await RemoteSettings.pollChanges({ trigger: "timer" });
} catch (e) {}
// DB was destroyed.
Assert.equal(
await client.db.getLastModified(),
null,
"Local DB was destroyed"
);
});
add_task(clear_state);
add_task(async function test_db_is_not_destroyed_when_state_is_server_error() {
// Since we don't mock the server endpoints to obtain the changeset of this
// collection, the call to `maybeSync()` will fail with network errors.
// Store some data in local DB.
await client.db.importChanges({}, 1515, []);
// Now obtain several failures in a row.
let lastError;
for (var i = 0; i < BROKEN_SYNC_THRESHOLD + 1; i++) {
try {
await RemoteSettings.pollChanges({ trigger: "timer" });
} catch (e) {
lastError = e;
}
}
Assert.ok(
/Cannot parse server content/.test(lastError.message),
"Error is about server"
);
// DB was not destroyed.
Assert.equal(
await client.db.getLastModified(),
1515,
"Local DB was not destroyed"
);
});
add_task(clear_state);

View File

@@ -0,0 +1,252 @@
"use strict";
var nextUniqId = 0;
function getNewUtils() {
const { Utils } = ChromeUtils.importESModule(
`resource://services-settings/Utils.sys.mjs?_${++nextUniqId}`
);
return Utils;
}
// A collection with a dump that's packaged on all builds where this test runs,
// including on Android at mobile/android/installer/package-manifest.in
const TEST_BUCKET = "main";
const TEST_COLLECTION = "password-recipes";
async function importData(records) {
await RemoteSettingsWorker._execute("_test_only_import", [
TEST_BUCKET,
TEST_COLLECTION,
records,
records[0]?.last_modified || 0,
]);
}
async function clear_state() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "0");
Services.prefs.clearUserPref("services.settings.server");
Services.prefs.clearUserPref("services.settings.preview_enabled");
}
add_setup(async function () {
// Set this env vars in order to test the code path where the
// server URL can only be overridden from Dev Tools.
// See `isRunningTests` in `services/settings/Utils.sys.mjs`.
const before = Services.env.get("MOZ_DISABLE_NONLOCAL_CONNECTIONS");
Services.env.set("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "0");
registerCleanupFunction(() => {
clear_state();
Services.env.set("MOZ_DISABLE_NONLOCAL_CONNECTIONS", before);
});
});
add_task(clear_state);
add_task(
{
skip_if: () => !AppConstants.RELEASE_OR_BETA,
},
async function test_server_url_cannot_be_toggled_in_release() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.equal(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was not read in release"
);
}
);
add_task(
{
skip_if: () => AppConstants.RELEASE_OR_BETA,
},
async function test_server_url_cannot_be_toggled_in_dev_nightly() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.notEqual(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was read in nightly/dev"
);
}
);
add_task(clear_state);
add_task(
{
skip_if: () => !AppConstants.RELEASE_OR_BETA,
},
async function test_preview_mode_cannot_be_toggled_in_release() {
Services.prefs.setBoolPref("services.settings.preview_enabled", true);
const Utils = getNewUtils();
Assert.ok(!Utils.PREVIEW_MODE, "Preview mode pref was not read in release");
}
);
add_task(clear_state);
add_task(
{
skip_if: () => AppConstants.RELEASE_OR_BETA,
},
async function test_preview_mode_cannot_be_toggled_in_dev_nightly() {
Services.prefs.setBoolPref("services.settings.preview_enabled", true);
const Utils = getNewUtils();
Assert.ok(Utils.PREVIEW_MODE, "Preview mode pref is read in dev/nightly");
}
);
add_task(clear_state);
add_task(
{
skip_if: () => !AppConstants.RELEASE_OR_BETA,
},
async function test_load_dumps_will_always_be_loaded_in_release() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.equal(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was not read"
);
Assert.ok(Utils.LOAD_DUMPS, "Dumps will always be loaded");
}
);
add_task(
{
skip_if: () => AppConstants.RELEASE_OR_BETA,
},
async function test_load_dumps_can_be_disabled_in_dev_nightly() {
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.notEqual(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was read"
);
Assert.ok(!Utils.LOAD_DUMPS, "Dumps are not loaded if server is not prod");
}
);
add_task(clear_state);
add_task(
async function test_server_url_can_be_changed_in_all_versions_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.notEqual(
Utils.SERVER_URL,
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0],
"Server url pref was read"
);
}
);
add_task(clear_state);
add_task(
async function test_preview_mode_can_be_changed_in_all_versions_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setBoolPref("services.settings.preview_enabled", true);
const Utils = getNewUtils();
Assert.ok(Utils.PREVIEW_MODE, "Preview mode pref was read");
}
);
add_task(clear_state);
add_task(
async function test_dumps_are_not_loaded_if_server_is_not_prod_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setStringPref(
"services.settings.server",
"http://localhost:8888/v1"
);
const Utils = getNewUtils();
Assert.ok(!Utils.LOAD_DUMPS, "Dumps won't be loaded");
// The section below ensures that the LOAD_DUMPS flag properly takes effect.
// The client is set up here rather than add_setup to avoid triggering the
// lazy getters that are behind the global Utils.LOAD_DUMPS. If they are
// triggered too early, then they will potentially cache different values
// for the server urls and environment variables and this test then won't be
// testing what we expect it to.
let client = new RemoteSettingsClient(TEST_COLLECTION);
const dump = await SharedUtils.loadJSONDump(TEST_BUCKET, TEST_COLLECTION);
let DUMP_LAST_MODIFIED = dump.timestamp;
// Dump is updated regularly, verify that the dump matches our expectations
// before running the test.
Assert.greater(
DUMP_LAST_MODIFIED,
1234,
"Assuming dump to be newer than dummy 1234"
);
await client.db.clear();
await importData([{ last_modified: 1234, id: "dummy" }]);
const after = await client.get();
Assert.deepEqual(
after,
[{ last_modified: 1234, id: "dummy" }],
"Should have kept the original import"
);
Assert.equal(
await client.getLastModified(),
1234,
"Should have kept the import's timestamp"
);
await client.db.clear();
}
);
add_task(clear_state);
add_task(
async function test_dumps_are_loaded_if_server_is_prod_if_running_for_devtools() {
Services.env.set("MOZ_REMOTE_SETTINGS_DEVTOOLS", "1");
Services.prefs.setStringPref(
"services.settings.server",
AppConstants.REMOTE_SETTINGS_SERVER_URLS[0]
);
const Utils = getNewUtils();
Assert.ok(Utils.LOAD_DUMPS, "dumps are loaded if prod");
}
);
add_task(clear_state);

View File

@@ -0,0 +1,952 @@
/* import-globals-from ../../../common/tests/unit/head_helpers.js */
"use strict";
const PREF_SETTINGS_SERVER = "services.settings.server";
const SIGNER_NAME = "onecrl.content-signature.mozilla.org";
const CERT_DIR = "test_remote_settings_signatures/";
const CHAIN_FILES = ["collection_signing_ee.pem", "collection_signing_int.pem"];
function getFileData(file) {
const stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
Ci.nsIFileInputStream
);
stream.init(file, -1, 0, 0);
const data = NetUtil.readInputStreamToString(stream, stream.available());
stream.close();
return data;
}
function getCertChain() {
const chain = [];
for (let file of CHAIN_FILES) {
chain.push(getFileData(do_get_file(CERT_DIR + file)));
}
return chain.join("\n");
}
let server;
let client;
add_setup(() => {
// Signature verification is enabled by default. We use a custom signer
// because these tests were originally written for OneCRL.
client = RemoteSettings("signed", { signerName: SIGNER_NAME });
Services.prefs.setStringPref("services.settings.loglevel", "debug");
// Set up an HTTP Server
server = new HttpServer();
server.start(-1);
registerCleanupFunction(() => {
Services.prefs.clearUserPref("services.settings.loglevel");
Services.prefs.clearUserPref(PREF_SETTINGS_SERVER);
server.stop(() => {});
});
});
add_task(async function test_check_signatures() {
// First, perform a signature verification with known data and signature
// to ensure things are working correctly
let verifier = Cc[
"@mozilla.org/security/contentsignatureverifier;1"
].createInstance(Ci.nsIContentSignatureVerifier);
const emptyData = "[]";
const emptySignature =
"p384ecdsa=zbugm2FDitsHwk5-IWsas1PpWwY29f0Fg5ZHeqD8fzep7AVl2vfcaHA7LdmCZ28qZLOioGKvco3qT117Q4-HlqFTJM7COHzxGyU2MMJ0ZTnhJrPOC1fP3cVQjU1PTWi9";
ok(
await verifier.asyncVerifyContentSignature(
emptyData,
emptySignature,
getCertChain(),
SIGNER_NAME,
Ci.nsIX509CertDB.AppXPCShellRoot
)
);
const collectionData =
'[{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:43:37Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"97fbf7c4-3ef2-f54f-0029-1ba6540c63ea","issuerName":"MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==","last_modified":2000,"serialNumber":"BAAAAAABA/A35EU="},{"details":{"bug":"https://bugzilla.mozilla.org/show_bug.cgi?id=1155145","created":"2016-01-18T14:48:11Z","name":"GlobalSign certs","who":".","why":"."},"enabled":true,"id":"e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc","issuerName":"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB","last_modified":3000,"serialNumber":"BAAAAAABI54PryQ="}]';
const collectionSignature =
"p384ecdsa=f4pA2tYM5jQgWY6YUmhUwQiBLj6QO5sHLD_5MqLePz95qv-7cNCuQoZnPQwxoptDtW8hcWH3kLb0quR7SB-r82gkpR9POVofsnWJRA-ETb0BcIz6VvI3pDT49ZLlNg3p";
ok(
await verifier.asyncVerifyContentSignature(
collectionData,
collectionSignature,
getCertChain(),
SIGNER_NAME,
Ci.nsIX509CertDB.AppXPCShellRoot
)
);
});
add_task(async function test_bad_signature_does_not_lead_to_empty_list() {
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
const x5u = `http://localhost:${server.identity.primaryPort}/x5u.pem`;
const networkCalls = [];
server.registerPathHandler(
"/v1/buckets/monitor/collections/changes/changeset",
(request, response) => {
response.write(
JSON.stringify({
changes: [
{
bucket: "main",
collection: "no-dump-no-local-data",
last_modified: 42,
},
],
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
}
);
server.registerPathHandler(
"/v1/buckets/monitor/collections/changes/changeset",
(request, response) => {
networkCalls.push(request);
response.write(
JSON.stringify({
changes: [
{
bucket: "main",
collection: "no-dump-no-local-data",
last_modified: 42,
},
],
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
}
);
server.registerPathHandler(
"/v1/buckets/main/collections/no-dump-no-local-data/changeset",
(request, response) => {
response.write(
JSON.stringify({
timestamp: 42,
changes: [],
metadata: {
signatures: [
{
signature: "bad-signature",
x5u,
},
],
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
}
);
server.registerPathHandler("/x5u.pem", (request, response) => {
response.write(getCertChain()); // At least cert will be valid.
response.setHeader("Content-Type", "text/plain; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
const clientEmpty = RemoteSettings("no-dump-no-local-data");
clientEmpty.verifySignature = true; // default
// Check that client.get() will initiate a sync,
// and that it will throw since the signature is bad,
// and not return an empty list (`emptyListFallback: false`)
let error;
try {
await clientEmpty.get({
emptyListFallback: false,
syncIfEmpty: true, // default value
});
} catch (exc) {
error = exc;
}
equal(error.name, "InvalidSignatureError");
// Even running client.sync() will throw and won't leave
// anything in the database.
error = null;
try {
await clientEmpty.sync();
} catch (exc) {
error = exc;
}
equal(error.name, "InvalidSignatureError");
equal(await clientEmpty.db.getLastModified(), null);
// Call .get() again will initiate another sync.
networkCalls.length = 0;
try {
await clientEmpty.get({
emptyListFallback: false,
syncIfEmpty: true, // default value
});
} catch (exc) {
error = exc;
}
Assert.greater(networkCalls.length, 0, "Network calls were made");
equal(error.name, "InvalidSignatureError");
});
add_task(async function test_check_synchronization_with_signatures() {
const port = server.identity.primaryPort;
const x5u = `http://localhost:${port}/test_remote_settings_signatures/test_cert_chain.pem`;
// Telemetry reports.
const TELEMETRY_SOURCE = client.identifier;
function registerHandlers(responses) {
function handleResponse(serverTimeMillis, request, response) {
const key = `${request.method}:${request.path}?${request.queryString}`;
const available = responses[key];
const sampled = available.length > 1 ? available.shift() : available[0];
if (!sampled) {
do_throw(
`unexpected ${request.method} request for ${request.path}?${request.queryString}`
);
}
response.setStatusLine(
null,
sampled.status.status,
sampled.status.statusText
);
// send the headers
for (let headerLine of sampled.sampleHeaders) {
let headerElements = headerLine.split(":");
response.setHeader(headerElements[0], headerElements[1].trimLeft());
}
// set the server date
response.setHeader("Date", new Date(serverTimeMillis).toUTCString());
response.write(sampled.responseBody);
}
for (let key of Object.keys(responses)) {
const keyParts = key.split(":");
const valueParts = keyParts[1].split("?");
const path = valueParts[0];
server.registerPathHandler(path, handleResponse.bind(null, 2000));
}
}
// set up prefs so the kinto updater talks to the test server
Services.prefs.setStringPref(
PREF_SETTINGS_SERVER,
`http://localhost:${server.identity.primaryPort}/v1`
);
// These are records we'll use in the test collections
const RECORD1 = {
details: {
bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
created: "2016-01-18T14:43:37Z",
name: "GlobalSign certs",
who: ".",
why: ".",
},
enabled: true,
id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea",
issuerName:
"MHExKDAmBgNVBAMTH0dsb2JhbFNpZ24gUm9vdFNpZ24gUGFydG5lcnMgQ0ExHTAbBgNVBAsTFFJvb3RTaWduIFBhcnRuZXJzIENBMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMQswCQYDVQQGEwJCRQ==",
last_modified: 2000,
serialNumber: "BAAAAAABA/A35EU=",
};
const RECORD2 = {
details: {
bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
created: "2016-01-18T14:48:11Z",
name: "GlobalSign certs",
who: ".",
why: ".",
},
enabled: true,
id: "e3bd531e-1ee4-7407-27ce-6fdc9cecbbdc",
issuerName:
"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB",
last_modified: 3000,
serialNumber: "BAAAAAABI54PryQ=",
};
const RECORD3 = {
details: {
bug: "https://bugzilla.mozilla.org/show_bug.cgi?id=1155145",
created: "2016-01-18T14:48:11Z",
name: "GlobalSign certs",
who: ".",
why: ".",
},
enabled: true,
id: "c7c49b69-a4ab-418e-92a9-e1961459aa7f",
issuerName:
"MIGBMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTElMCMGA1UECxMcUHJpbWFyeSBPYmplY3QgUHVibGlzaGluZyBDQTEwMC4GA1UEAxMnR2xvYmFsU2lnbiBQcmltYXJ5IE9iamVjdCBQdWJsaXNoaW5nIENB",
last_modified: 4000,
serialNumber: "BAAAAAABI54PryQ=",
};
const RECORD1_DELETION = {
deleted: true,
enabled: true,
id: "97fbf7c4-3ef2-f54f-0029-1ba6540c63ea",
last_modified: 3500,
};
// Check that a signature on an empty collection is OK
// We need to set up paths on the HTTP server to return specific data from
// specific paths for each test. Here we prepare data for each response.
// A cert chain response (this the cert chain that contains the signing
// cert, the root and any intermediates in between). This is used in each
// sync.
const RESPONSE_CERT_CHAIN = {
comment: "RESPONSE_CERT_CHAIN",
sampleHeaders: ["Content-Type: text/plain; charset=UTF-8"],
status: { status: 200, statusText: "OK" },
responseBody: getCertChain(),
};
// A server settings response. This is used in each sync.
const RESPONSE_SERVER_SETTINGS = {
comment: "RESPONSE_SERVER_SETTINGS",
sampleHeaders: [
"Access-Control-Allow-Origin: *",
"Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
"Content-Type: application/json; charset=UTF-8",
"Server: waitress",
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
settings: {
batch_max_requests: 25,
},
url: `http://localhost:${port}/v1/`,
documentation: "https://kinto.readthedocs.org/",
version: "1.5.1",
commit: "cbc6f58",
hello: "kinto",
}),
};
// This is the initial, empty state of the collection. This is only used
// for the first sync.
const RESPONSE_EMPTY_INITIAL = {
comment: "RESPONSE_EMPTY_INITIAL",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "1000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 1000,
metadata: {
signatures: [
{
x5u,
signature:
"vxuAg5rDCB-1pul4a91vqSBQRXJG_j7WOYUTswxRSMltdYmbhLRH8R8brQ9YKuNDF56F-w6pn4HWxb076qgKPwgcEBtUeZAO_RtaHXRkRUUgVzAr86yQL4-aJTbv3D6u",
},
],
},
changes: [],
}),
};
// Here, we map request method and path to the available responses
const emptyCollectionResponses = {
"GET:/test_remote_settings_signatures/test_cert_chain.pem?": [
RESPONSE_CERT_CHAIN,
],
"GET:/v1/?": [RESPONSE_SERVER_SETTINGS],
"GET:/v1/buckets/main/collections/signed/changeset?_expected=1000": [
RESPONSE_EMPTY_INITIAL,
],
};
//
// 1.
// - collection: undefined -> []
// - timestamp: undefined -> 1000
//
// .. and use this map to register handlers for each path
registerHandlers(emptyCollectionResponses);
// Clear events snapshot.
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
// With all of this set up, we attempt a sync. This will resolve if all is
// well and throw if something goes wrong.
await client.maybeSync(1000);
equal((await client.get()).length, 0);
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.SUCCESS,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
]);
//
// 2.
// - collection: [] -> [RECORD2, RECORD1]
// - timestamp: 1000 -> 3000
//
// Check that some additions (2 records) to the collection have a valid
// signature.
// This response adds two entries (RECORD1 and RECORD2) to the collection
const RESPONSE_TWO_ADDED = {
comment: "RESPONSE_TWO_ADDED",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "3000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 3000,
metadata: {
signatures: [
{
x5u,
signature:
"dwhJeypadNIyzGj3QdI0KMRTPnHhFPF_j73mNrsPAHKMW46S2Ftf4BzsPMvPMB8h0TjDus13wo_R4l432DHe7tYyMIWXY0PBeMcoe5BREhFIxMxTsh9eGVXBD1e3UwRy",
},
],
},
changes: [RECORD2, RECORD1],
}),
};
const twoItemsResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=3000&_since=1000":
[RESPONSE_TWO_ADDED],
};
registerHandlers(twoItemsResponses);
await client.maybeSync(3000);
equal((await client.get()).length, 2);
//
// 3.
// - collection: [RECORD2, RECORD1] -> [RECORD2, RECORD3]
// - timestamp: 3000 -> 4000
//
// Check the collection with one addition and one removal has a valid
// signature
const THREE_ITEMS_SIG =
"MIEmNghKnkz12UodAAIc3q_Y4a3IJJ7GhHF4JYNYmm8avAGyPM9fYU7NzVo94pzjotG7vmtiYuHyIX2rTHTbT587w0LdRWxipgFd_PC1mHiwUyjFYNqBBG-kifYk7kEw";
// Remove RECORD1, add RECORD3
const RESPONSE_ONE_ADDED_ONE_REMOVED = {
comment: "RESPONSE_ONE_ADDED_ONE_REMOVED ",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "4000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: THREE_ITEMS_SIG,
},
],
},
changes: [RECORD3, RECORD1_DELETION],
}),
};
const oneAddedOneRemovedResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=4000&_since=3000":
[RESPONSE_ONE_ADDED_ONE_REMOVED],
};
registerHandlers(oneAddedOneRemovedResponses);
await client.maybeSync(4000);
equal((await client.get()).length, 2);
//
// 4.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 4100
//
// Check the signature is still valid with no operation (no changes)
// Leave the collection unchanged
const RESPONSE_EMPTY_NO_UPDATE = {
comment: "RESPONSE_EMPTY_NO_UPDATE ",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "4000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: THREE_ITEMS_SIG,
},
],
},
changes: [],
}),
};
const noOpResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=4100&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE],
};
registerHandlers(noOpResponses);
await client.maybeSync(4100);
equal((await client.get()).length, 2);
//
// 5.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 5000
//
// Check the collection is reset when the signature is invalid.
// Client will:
// - Fetch metadata (with bad signature)
// - Perform the sync (fetch empty changes)
// - Refetch the metadata and the whole collection
// - Validate signature successfully, but with no changes to emit.
const RESPONSE_COMPLETE_INITIAL = {
comment: "RESPONSE_COMPLETE_INITIAL ",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "4000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: THREE_ITEMS_SIG,
},
],
},
changes: [RECORD2, RECORD3],
}),
};
const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG = {
...RESPONSE_EMPTY_NO_UPDATE,
responseBody: JSON.stringify({
timestamp: 4000,
metadata: {
signatures: [
{
x5u,
signature: "aW52YWxpZCBzaWduYXR1cmUK",
},
],
},
changes: [],
}),
};
const badSigGoodSigResponses = {
// The first collection state is the three item collection (since
// there was sync with no updates before) - but, since the signature is wrong,
// another request will be made...
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE_BAD_SIG],
// Subsequent signature returned is a valid one for the three item
// collection.
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [
RESPONSE_COMPLETE_INITIAL,
],
};
registerHandlers(badSigGoodSigResponses);
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
let syncEventSent = false;
client.on("sync", () => {
syncEventSent = true;
});
await client.maybeSync(5000);
equal((await client.get()).length, 2);
// since we only fixed the signature, and no data was changed, the sync event
// was not sent.
equal(syncEventSent, false);
// ensure that the failure count is incremented for a succesful sync with an
// (initial) bad signature - only SERVICES_SETTINGS_SYNC_SIG_FAIL should
// increment.
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.SIGNATURE_ERROR,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
]);
//
// 6.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 5000
//
// Check the collection is reset when the signature is invalid.
// Client will:
// - Fetch metadata (with bad signature)
// - Perform the sync (fetch empty changes)
// - Refetch the whole collection and metadata
// - Sync will be no-op since local is equal to server, no changes to emit.
const badSigGoodOldResponses = {
// The first collection state is the current state (since there's no update
// - but, since the signature is wrong, another request will be made)
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE_BAD_SIG],
// The next request is for the full collection. This will be
// checked against the valid signature and last_modified times will be
// compared. Sync should be a no-op, even though the signature is good,
// because the local collection is newer.
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [
RESPONSE_EMPTY_INITIAL,
],
};
// ensure our collection hasn't been replaced with an older, empty one
equal((await client.get()).length, 2, "collection was restored");
registerHandlers(badSigGoodOldResponses);
syncEventSent = false;
client.on("sync", () => {
syncEventSent = true;
});
await client.maybeSync(5000);
// Local data was unchanged, since it was never than the one returned by the server,
// thus the sync event is not sent.
equal(syncEventSent, false, "event was not sent");
//
// 7.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 5000
//
// Check that a tampered local DB will be overwritten and
// sync event contain the appropriate data.
const RESPONSE_COMPLETE_BAD_SIG = {
...RESPONSE_EMPTY_NO_UPDATE,
responseBody: JSON.stringify({
timestamp: 5000,
metadata: {
signatures: [
{
x5u,
signature: "aW52YWxpZCBzaWduYXR1cmUK",
},
],
},
changes: [RECORD2, RECORD3],
}),
};
const badLocalContentGoodSigResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000&_since=3900":
[RESPONSE_COMPLETE_BAD_SIG],
"GET:/v1/buckets/main/collections/signed/changeset?_expected=5000": [
RESPONSE_COMPLETE_INITIAL,
],
};
registerHandlers(badLocalContentGoodSigResponses);
// we create a local state manually here, in order to test that the sync event data
// properly contains created, updated, and deleted records.
// the local DB contains same id as RECORD2 and a fake record.
// the final server collection contains RECORD2 and RECORD3
const localId = "0602b1b2-12ab-4d3a-b6fb-593244e7b035";
await client.db.importChanges(
{ signatures: [{ x5u, signature: "abc" }] },
3900,
[
{ ...RECORD2, last_modified: 1234567890, serialNumber: "abc" },
{ id: localId },
],
{
clear: true,
}
);
let syncData = null;
client.on("sync", ({ data }) => {
syncData = data;
});
// Clear events snapshot.
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
// Events telemetry is sampled on released, use fake channel.
await client.maybeSync(5000);
// We should report a corruption_error.
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: client.identifier,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.CORRUPTION_ERROR,
source: client.identifier,
trigger: "manual",
duration: d => parseInt(d) > 0,
},
]);
// The local data was corrupted, and the Telemetry status reflects it.
// But the sync overwrote the bad data and was eventually a success.
// Since local data was replaced, we use records IDs to determine
// what was created and deleted. And bad local data will appear
// in the sync event as deleted.
equal(syncData.current.length, 2);
equal(syncData.created.length, 1);
equal(syncData.created[0].id, RECORD3.id);
equal(syncData.updated.length, 1);
equal(syncData.updated[0].old.serialNumber, "abc");
equal(syncData.updated[0].new.serialNumber, RECORD2.serialNumber);
equal(syncData.deleted.length, 1);
equal(syncData.deleted[0].id, localId);
//
// 8.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3] (unchanged because of error)
// - timestamp: 4000 -> 6000
//
// Check that a failing signature throws after retry, and that sync changes
// are not applied.
const RESPONSE_ONLY_RECORD4_BAD_SIG = {
comment: "Create RECORD4",
sampleHeaders: [
"Content-Type: application/json; charset=UTF-8",
'ETag: "6000"',
],
status: { status: 200, statusText: "OK" },
responseBody: JSON.stringify({
timestamp: 6000,
metadata: {
signatures: [
{
x5u,
signature: "aaaaaaaaaaaaaaaaaaaaaaaa", // sig verifier wants proper length or will crash.
},
],
},
changes: [
{
id: "f765df30-b2f1-42f6-9803-7bd5a07b5098",
last_modified: 6000,
},
],
}),
};
const RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000 = {
...RESPONSE_EMPTY_NO_UPDATE,
responseBody: JSON.stringify({
timestamp: 6000,
metadata: {
signatures: [
{
x5u,
signature: "aW52YWxpZCBzaWduYXR1cmUK",
},
],
},
changes: [],
}),
};
const allBadSigResponses = {
"GET:/v1/buckets/main/collections/signed/changeset?_expected=6000&_since=4000":
[RESPONSE_EMPTY_NO_UPDATE_BAD_SIG_6000],
"GET:/v1/buckets/main/collections/signed/changeset?_expected=6000": [
RESPONSE_ONLY_RECORD4_BAD_SIG,
],
};
// Reset telemetry capture.
Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_ALL_CHANNELS, true);
Services.fog.testResetFOG();
enableUptakeMetric();
registerHandlers(allBadSigResponses);
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
// Ensure that the failure is reflected in the accumulated telemetry:
assertTelemetryEvents([
{
value: UptakeTelemetry.STATUS.SYNC_START,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
{
value: UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR,
source: TELEMETRY_SOURCE,
trigger: "manual",
},
]);
// When signature fails after retry, the local data present before sync
// should be maintained (if its signature is valid).
ok(
arrayEqual(
(await client.get()).map(r => r.id),
[RECORD3.id, RECORD2.id]
),
"Local records were not changed"
);
// And local data should still be valid.
await client.get({ verifySignature: true }); // Not raising.
//
// 9.
// - collection: [RECORD2, RECORD3] -> [] (cleared)
// - timestamp: 4000 -> 6000
//
// Check that local data is cleared during sync if signature is not valid.
await client.db.create({
id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81",
last_modified: 42,
tampered: true,
});
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
// Since local data was tampered, it was cleared.
equal((await client.get()).length, 0, "Local database is now empty.");
//
// 10.
// - collection: [RECORD2, RECORD3] -> [] (cleared)
// - timestamp: 4000 -> 6000
//
// Check that local data is cleared during sync if signature is not valid.
await client.db.create({
id: "c6b19c67-2e0e-4a82-b7f7-1777b05f3e81",
last_modified: 42,
tampered: true,
});
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
// Since local data was tampered, it was cleared.
equal((await client.get()).length, 0, "Local database is now empty.");
//
// 11.
// - collection: [RECORD2, RECORD3] -> [RECORD2, RECORD3]
// - timestamp: 4000 -> 6000
//
// Check that local data is restored if signature was valid before sync.
const sigCalls = [];
let i = 0;
client._verifier = {
async asyncVerifyContentSignature(serialized) {
sigCalls.push(serialized);
console.log(`verify call ${i}`);
return [
false, // After importing changes.
true, // When checking previous local data.
false, // Still fail after retry.
true, // When checking previous local data again.
][i++];
},
};
// Create an extra record. It will have a valid signature locally
// thanks to the verifier mock.
await client.db.importChanges(
{
signatures: [{ x5u, signature: "aa" }],
},
4000,
[
{
id: "extraId",
last_modified: 42,
},
]
);
equal((await client.get()).length, 1);
// Now sync, but importing changes will have failing signature,
// and so will retry (see `sigResults`).
await Assert.rejects(
client.maybeSync(6000),
RemoteSettingsClient.InvalidSignatureError,
"Sync failed as expected (bad signature after retry)"
);
equal(i, 4, "sync has retried as expected");
// Make sure that we retried on a blank DB. The extra record should
// have been deleted when we validated the signature the second time.
// Since local data was tampered, it was cleared.
ok(/extraId/.test(sigCalls[0]), "extra record when importing changes");
ok(/extraId/.test(sigCalls[1]), "extra record when checking local");
ok(!/extraId/.test(sigCalls[2]), "db was flushed before retry");
ok(/extraId/.test(sigCalls[3]), "when checking local after retry");
});

View File

@@ -0,0 +1,16 @@
-----BEGIN CERTIFICATE-----
MIICdTCCAV2gAwIBAgIUdVZo8xeazUY3Fzm6cy61HaSjFJUwDQYJKoZIhvcNAQEL
BQAwIzEhMB8GA1UEAwwYY29sbGVjdGlvbi1zaWduZXItaW50LUNBMCIYDzIwMjQx
MTI3MDAwMDAwWhgPMjAyNzAyMDUwMDAwMDBaMCYxJDAiBgNVBAMMG2NvbGxlY3Rp
b24tc2lnbmVyLWVlLWludC1DQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABKFockM2
K1x7GInzeRVGFaHHP7SN7oY+AikV22COJS3ktxMtqM6Y6DFTTmqcDAsJyNY5regy
BuW6gTRzoR+jMOBdqMluQ4P+J4c9qXEDviiIz/AC8Fr3Gh/dzIN0qm6pzqNIMEYw
EwYDVR0lBAwwCgYIKwYBBQUHAwMwLwYDVR0RBCgwJoIkb25lY3JsLmNvbnRlbnQt
c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBCwUAA4IBAQBVHMPKgXKX
EVVTDRw8lF0MQBHUPA2+4f9JDsaq5AexGpFqfDzFobrzZnzKQyaODeoalhASraWs
GvhXrXFeSkSLqNpw657JXuIvVOULJjr2h9uA9N837KgFosvLLIIoKs1zeXG0LSYy
NSJBSme+n8ETV2d8RJ/w575rhnheDz6GcVH0hduvQR0g7dg8ZMtdfi16sjKaJRup
coH4N8t4YhLqM4PyrEATZyTEW5jOCKCb91FWgPu0ovfQz+t3jjS7TPpUa3IYJS8V
eFUHXUAXFFgD0xCl7P/V+kopWSijZHT8mwnPfn2z9EmV5ijioIsw2MF+FACkPWa1
o2NzujCdDAEl
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
issuer:collection-signer-int-CA
subject:collection-signer-ee-int-CA
subjectKey:secp384r1
extension:extKeyUsage:codeSigning
extension:subjectAlternativeName:onecrl.content-signature.mozilla.org

View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDAzCCAeugAwIBAgIUf3MHKqXo3U9dG7tkPLy8t7D+1o4wDQYJKoZIhvcNAQEL
BQAwKTEnMCUGA1UEAwweeHBjc2hlbGwgc2lnbmVkIGFwcHMgdGVzdCByb290MCIY
DzIwMjQxMTI3MDAwMDAwWhgPMjAyNzAyMDUwMDAwMDBaMCMxITAfBgNVBAMMGGNv
bGxlY3Rpb24tc2lnbmVyLWludC1DQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBALqIUahEjhbWQf1utogGNhA9PBPZ6uQ1SrTs9WhXbCR7wcclqODYH72x
nAabbhqG8mvir1p1a2pkcQh6pVqnRYf3HNUknAJ+zUP8HmnQOCApk6sgw0nk27lM
wmtsDu0Vgg/xfq1pGrHTAjqLKkHup3DgDw2N/WYLK7AkkqR9uYhheZCxV5A90jvF
4LhIH6g304hD7ycW2FW3ZlqqfgKQLzp7EIAGJMwcbJetlmFbt+KWEsB1MaMMkd20
yvf8rR0l0wnvuRcOp2jhs3svIm9p47SKlWEd7ibWJZ2rkQhONsscJAQsvxaLL+Xx
j5kXMbiz/kkj+nJRxDHVA6zaGAo17Y0CAwEAAaMlMCMwDAYDVR0TBAUwAwEB/zAT
BgNVHSUEDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAjJAZX8SZOh1H
xLqKHgt2EiFloIW3k3aAnTFQPAEUp+IBXjJdA8cFzEDSHo0dTr8jBhR8V1gwy8+Z
1i6loA0rgJUdDhUP24SqbfsdF6kcltlBfjR4lBfbZq0BClMiNDL2ZOQS4neF15fN
WXUv2UE9ooKNUjgIzXHbU/ykbwTKB7Bmsh+r5CYs9LMNF5P/QDAlDNwvZglxiyyq
61fi06lnBXff0zObSdV/8Wm0PQyHEBfNY3L94wuIFxGsX0YngO7CCtaIFIeymzob
8SvH4W0vfeqYzDFM4PfgFbDs4KnAzJS+WwWq2HRDsHMZTDZVVXVA61LXYhFEOam1
ASCDwhnClA==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,4 @@
issuer:xpcshell signed apps test root
subject:collection-signer-int-CA
extension:basicConstraints:cA,
extension:extKeyUsage:codeSigning

View File

@@ -0,0 +1,152 @@
const { Database } = ChromeUtils.importESModule(
"resource://services-settings/Database.sys.mjs"
);
const { RemoteSettings } = ChromeUtils.importESModule(
"resource://services-settings/remote-settings.sys.mjs"
);
let server;
let startupClients;
add_setup(async () => {
// Disable signature verification of collections listed in test startup.json.mzlz4
// since their metadata will eventually expire.
startupClients = [
"message-groups",
"nimbus-desktop-experiments",
"search-categorization",
"tracking-protection-lists",
"query-stripping",
"cfr",
].map(id => {
const c = RemoteSettings(id);
c.verifySignature = false;
return c;
});
// Setup an HTTP server to serve the 'startup.json.mzlz4' bundle.
server = new HttpServer();
server.start(-1);
server.registerDirectory(
"/cdn/bundles/",
do_get_file("test_remote_settings_startup_bundle")
);
server.registerPathHandler("/v1/", (request, response) => {
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: `http://localhost:${server.identity.primaryPort}/cdn/`,
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v1`
);
registerCleanupFunction(() => {
server.stop(() => {});
Services.prefs.clearUserPref("services.settings.loglevel");
});
});
async function clear_state() {
await Database.destroy();
RemoteSettings._ongoingExtractBundlePromise = null;
}
add_task(async function test_bundle_is_pulled_when_get_needs_sync() {
const client = startupClients[0];
Assert.ok(
!(await Utils.hasLocalDump(client.bucketName, client.collectionName)),
"Client has no packaged dump"
);
const records = await client.get();
Assert.equal(records.length, 6, "Records were read from startup bundle");
});
add_task(clear_state);
add_task(
async function test_signature_of_extracted_data_from_bundle_is_verified() {
const c = RemoteSettings("tracking-protection-lists"); // part of startup.json.mzlz4
c.verifySignature = true;
let called = null;
c.validateCollectionSignature = (records, timestamp, metadata) => {
called = { records, timestamp, metadata };
};
await c.get();
Assert.ok(!!called.records.length);
Assert.greaterOrEqual(called.timestamp, 1694684362860);
Assert.ok(called.metadata.flags.includes("startup"));
}
);
add_task(clear_state);
add_task(async function test_bundle_is_not_importent_when_signature_fails() {
const c = RemoteSettings("tracking-protection-lists"); // part of startup.json.mzlz4
c.verifySignature = true;
let called = false;
c.validateCollectionSignature = () => {
called = true;
throw new Error("boom!");
};
let error;
try {
await c.get({ emptyListFallback: false });
Assert.ok(false, ".get() should fail without network");
} catch (e) {
error = e;
}
Assert.ok(called, "Signature was verified");
Assert.equal(
error.name,
"UnknownCollectionError",
".get() fails without network and with bad startup bundle"
);
});
add_task(clear_state);
add_task(async function test_sync_occurs_if_collection_not_part_of_bundle() {
const c = RemoteSettings("foo");
let error;
try {
await c.get({ emptyListFallback: false });
Assert.ok(false, ".get() should fail when bundle disabled");
} catch (e) {
error = e;
}
Assert.equal(
error.name,
"UnknownCollectionError",
".get() fails to sync data"
);
});
add_task(clear_state);
add_task(async function test_several_clients_wait_for_bundle() {
// several clients calling .get() in parallel will all take content from bundle.
const results = await Promise.allSettled(
startupClients.map(c => c.get({ emptyListFallback: false }))
);
Assert.deepEqual(
[6, 70, 5, "UnknownCollectionError", 3, 11],
results.map(({ status, value, reason }) =>
status == "fulfilled" ? value.length : reason.name
)
);
});
add_task(clear_state);

View File

@@ -0,0 +1,65 @@
"use strict";
async function clear_state() {
await new SyncHistory("").clear();
}
add_task(clear_state);
add_task(async function test_entries_are_stored_by_source() {
const history = new SyncHistory();
await history.store(42, "success", { pi: "3.14" });
// Check that history is isolated by source.
await new SyncHistory("main/cfr").store(88, "error");
const l = await history.list();
Assert.deepEqual(l, [
{
timestamp: 42,
status: "success",
infos: { pi: "3.14" },
datetime: new Date(42),
},
]);
});
add_task(clear_state);
add_task(
async function test_old_entries_are_removed_keep_fixed_size_per_source() {
const history = new SyncHistory("settings-sync", { size: 3 });
const anotherHistory = await new SyncHistory("main/cfr");
await history.store(42, "success");
await history.store(41, "sync_error");
await history.store(43, "up_to_date");
let l = await history.list();
Assert.equal(l.length, 3);
await history.store(44, "success");
await anotherHistory.store(44, "success");
l = await history.list();
Assert.equal(l.length, 3);
Assert.ok(!l.map(e => e.timestamp).includes(41));
l = await anotherHistory.list();
Assert.equal(l.length, 1);
}
);
add_task(clear_state);
add_task(async function test_entries_are_sorted_by_timestamp_desc() {
const history = new SyncHistory("settings-sync");
await history.store(42, "success");
await history.store(41, "sync_error");
await history.store(44, "up_to_date");
const l = await history.list();
Assert.deepEqual(
l.map(e => e.timestamp),
[44, 42, 41]
);
});
add_task(clear_state);

View File

@@ -0,0 +1,299 @@
const BinaryOutputStream = Components.Constructor(
"@mozilla.org/binaryoutputstream;1",
"nsIBinaryOutputStream",
"setOutputStream"
);
const server = new HttpServer();
server.start(-1);
registerCleanupFunction(() => server.stop(() => {}));
const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
const proxyServer = new HttpServer();
proxyServer.identity.add("http", "localhost", server.identity.primaryPort);
proxyServer.start(-1);
registerCleanupFunction(() => proxyServer.stop(() => {}));
const PROXY_PORT = proxyServer.identity.primaryPort;
// A sequence of bytes that would become garbage if it were to be read as UTF-8:
// - 0xEF 0xBB 0xBF is a byte order mark.
// - 0xC0 on its own is invalid (it's the first byte of a 2-byte encoding).
const INVALID_UTF_8_BYTES = [0xef, 0xbb, 0xbf, 0xc0];
server.registerPathHandler("/binary.dat", (request, response) => {
response.setStatusLine(null, 201, "StatusLineHere");
response.setHeader("headerName", "HeaderValue: HeaderValueEnd");
let binaryOut = new BinaryOutputStream(response.bodyOutputStream);
binaryOut.writeByteArray([0xef, 0xbb, 0xbf, 0xc0]);
});
// HTTPS requests are proxied with CONNECT, but our test server is HTTP,
// which means that the proxy will receive GET http://localhost:port.
var proxiedCount = 0;
proxyServer.registerPrefixHandler("/", (request, response) => {
++proxiedCount;
Assert.equal(request.path, "/binary.dat", `Proxy request ${proxiedCount}`);
// Close connection without sending any response.
response.seizePower();
response.finish();
});
add_task(async function test_utils_fetch_binary() {
let res = await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`);
Assert.equal(res.status, 201, "res.status");
Assert.equal(res.statusText, "StatusLineHere", "res.statusText");
Assert.equal(
res.headers.get("headerName"),
"HeaderValue: HeaderValueEnd",
"Utils.fetch should return the header"
);
Assert.deepEqual(
Array.from(new Uint8Array(await res.arrayBuffer())),
INVALID_UTF_8_BYTES,
"Binary response body should be returned as is"
);
});
add_task(async function test_utils_fetch_binary_as_text() {
let res = await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`);
Assert.deepEqual(
Array.from(await res.text(), c => c.charCodeAt(0)),
[65533],
"Interpreted as UTF-8, the response becomes garbage"
);
});
add_task(async function test_utils_fetch_binary_as_json() {
let res = await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`);
await Assert.rejects(
res.json(),
/SyntaxError: JSON.parse: unexpected character/,
"Binary data is invalid JSON"
);
});
add_task(async function test_utils_fetch_has_conservative() {
let channelPromise = TestUtils.topicObserved("http-on-modify-request");
await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`);
let channel = (await channelPromise)[0].QueryInterface(Ci.nsIHttpChannel);
Assert.equal(channel.URI.spec, `${SERVER_BASE_URL}/binary.dat`, "URL OK");
let internalChannel = channel.QueryInterface(Ci.nsIHttpChannelInternal);
Assert.ok(internalChannel.beConservative, "beConservative flag is set");
});
add_task(async function test_utils_fetch_has_conservative() {
let channelPromise = TestUtils.topicObserved("http-on-modify-request");
await Utils.fetch(`${SERVER_BASE_URL}/binary.dat`);
let channel = (await channelPromise)[0].QueryInterface(Ci.nsIHttpChannel);
Assert.equal(channel.URI.spec, `${SERVER_BASE_URL}/binary.dat`, "URL OK");
let internalChannel = channel.QueryInterface(Ci.nsIHttpChannelInternal);
Assert.ok(internalChannel.beConservative, "beConservative flag is set");
});
add_task(async function test_utils_fetch_with_bad_proxy() {
Services.prefs.setIntPref("network.proxy.type", 1);
Services.prefs.setStringPref("network.proxy.http", "127.0.0.1");
Services.prefs.setIntPref("network.proxy.http_port", PROXY_PORT);
Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true);
// The URL that we're going to request.
const DESTINATION_URL = `${SERVER_BASE_URL}/binary.dat`;
Assert.equal(proxiedCount, 0, "Proxy not used yet");
{
info("Bad proxy, default prefs");
let res = await Utils.fetch(DESTINATION_URL);
Assert.equal(res.status, 201, "Bypassed bad proxy");
// 10 instead of 1 because of reconnect attempts after a dropped request.
Assert.equal(proxiedCount, 10, "Proxy was used by HttpChannel");
}
// Disables the failover logic from HttpChannel.
Services.prefs.setBoolPref("network.proxy.failover_direct", false);
proxiedCount = 0;
{
info("Bad proxy, disabled network.proxy.failover_direct");
let res = await Utils.fetch(DESTINATION_URL);
Assert.equal(res.status, 201, "Bypassed bad proxy");
// 10 instead of 1 because of reconnect attempts after a dropped request.
Assert.equal(proxiedCount, 10, "Proxy was used by ServiceRequest");
}
proxiedCount = 0;
{
info("Using internal option of Utils.fetch: bypassProxy=true");
let res = await Utils.fetch(DESTINATION_URL, { bypassProxy: true });
Assert.equal(res.status, 201, "Bypassed bad proxy");
Assert.equal(proxiedCount, 0, "Not using proxy when bypassProxy=true");
}
// Disables the failover logic from ServiceRequest/Utils.fetch
Services.prefs.setBoolPref("network.proxy.allow_bypass", false);
proxiedCount = 0;
info("Bad proxy, disabled network.proxy.allow_bypass");
await Assert.rejects(
Utils.fetch(DESTINATION_URL),
/NetworkError/,
"Bad proxy request should fail without failover"
);
// 10 instead of 1 because of reconnect attempts after a dropped request.
Assert.equal(proxiedCount, 10, "Attempted to use proxy again");
Services.prefs.clearUserPref("network.proxy.type");
Services.prefs.clearUserPref("network.proxy.http");
Services.prefs.clearUserPref("network.proxy.http_port");
Services.prefs.clearUserPref("network.proxy.allow_hijacking_localhost");
Services.prefs.clearUserPref("network.proxy.failover_direct");
Services.prefs.clearUserPref("network.proxy.allow_bypass");
});
add_task(async function test_base_attachment_url_depends_on_server() {
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v1`
);
server.registerPathHandler("/v1/", (request, response) => {
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: "http://default-url.com/",
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
const before = await Utils.baseAttachmentsURL();
Assert.equal(before, "http://default-url.com/");
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v2`
);
Assert.equal(
Services.prefs.getStringPref("services.settings.server"),
Utils.SERVER_URL
);
server.registerPathHandler("/v2/", (request, response) => {
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: "http://some-cdn-url.org/",
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
const after = await Utils.baseAttachmentsURL();
Assert.equal(after, "http://some-cdn-url.org/");
});
add_task(async function test_base_attachment_url_reads_from_prefs() {
Services.prefs.setStringPref(
"services.settings.base_attachments_url",
`${Utils.SERVER_URL}|https://other/`
);
Assert.equal(await Utils.baseAttachmentsURL(), "https://other/");
});
add_task(async function test_base_attachment_url_is_robust_to_bad_saved_data() {
server.registerPathHandler("/v1/", (request, response) => {
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: "http://some-cdn-url.org/",
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
for (const badValue of [
"",
"|",
Utils.SERVER_URL,
`${Utils.SERVER_URL}|https://other`, // Missing trailing slash.
`${Utils.SERVER_URL}|https://other/path`, // Missing trailing slash.
]) {
Services.prefs.setStringPref(
"services.settings.base_attachments_url",
badValue
);
Assert.equal(
await Utils.baseAttachmentsURL(),
"http://some-cdn-url.org/",
"Fallsback to server's"
);
}
});
add_task(async function test_base_attachment_url_does_not_retry_by_default() {
Services.prefs.clearUserPref("services.settings.base_attachments_url");
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v1`
);
let requests = 0;
server.registerPathHandler("/v1/", (request, response) => {
requests++;
response.setStatusLine(null, 503, "Service Unavailable");
});
try {
await Utils.baseAttachmentsURL({ retryWaitMsec: 10 });
Assert.ok(false, "should throw on error");
} catch (e) {}
Assert.equal(requests, 1);
});
add_task(async function test_base_attachment_url_can_retry() {
Services.prefs.clearUserPref("services.settings.base_attachments_url");
Services.prefs.setStringPref(
"services.settings.server",
`http://localhost:${server.identity.primaryPort}/v1`
);
let requests = 0;
server.registerPathHandler("/v1/", (request, response) => {
requests++;
if (requests < 4) {
response.setStatusLine(null, 503, "Service Unavailable");
return;
}
response.write(
JSON.stringify({
capabilities: {
attachments: {
base_url: "http://some-cdn-url.org/",
},
},
})
);
response.setHeader("Content-Type", "application/json; charset=UTF-8");
response.setStatusLine(null, 200, "OK");
});
Assert.equal(
await Utils.baseAttachmentsURL({ retries: 3, retryWaitMsec: 10 }),
"http://some-cdn-url.org/"
);
Assert.equal(requests, 4);
});

View File

@@ -0,0 +1,79 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const server = new HttpServer();
server.start(-1);
registerCleanupFunction(() => server.stop(() => {}));
const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
const proxyServer = new HttpServer();
proxyServer.start(-1);
const PROXY_PORT = proxyServer.identity.primaryPort;
proxyServer.stop();
server.registerPathHandler("/destination", (request, response) => {
response.setStatusLine(null, 412);
});
function assertBypassTelemetryEvents(expectedEvents) {
const events = Glean.serviceRequest.bypassProxyInfo.testGetValue() ?? [];
Assert.equal(events.length, expectedEvents.length);
for (let i = 0; i < expectedEvents.length; i++) {
Assert.deepEqual(events[i].extra, {
value: expectedEvents[i].value,
...expectedEvents[i].extra,
});
}
Services.fog.testResetFOG();
}
add_task(async function test_telemetry() {
const DESTINATION_URL = `${SERVER_BASE_URL}/destination`;
{
let res = await Utils.fetch(DESTINATION_URL);
Assert.equal(res.status, 412, "fetch without proxy succeeded");
}
assertBypassTelemetryEvents([]);
Services.prefs.setIntPref("network.proxy.type", 1);
Services.prefs.setStringPref("network.proxy.http", "127.0.0.1");
Services.prefs.setIntPref("network.proxy.http_port", PROXY_PORT);
Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true);
{
let res = await Utils.fetch(DESTINATION_URL);
Assert.equal(res.status, 412, "fetch with broken proxy succeeded");
}
// Note: failover handled by HttpChannel, hence no failover here.
assertBypassTelemetryEvents([]);
// Disable HttpChannel failover in favor of Utils.fetch's implementation.
Services.prefs.setBoolPref("network.proxy.failover_direct", false);
{
let res = await Utils.fetch(DESTINATION_URL);
Assert.equal(res.status, 412, "fetch succeeded with bypassProxy feature");
}
assertBypassTelemetryEvents([
{
category: "service_request",
method: "bypass",
object: "proxy_info",
value: "remote-settings",
extra: {
source: "prefs",
type: "manual",
},
},
]);
Services.prefs.setBoolPref("network.proxy.allow_bypass", false);
await Assert.rejects(
Utils.fetch(DESTINATION_URL),
/NetworkError/,
"Request without failover fails"
);
assertBypassTelemetryEvents([]);
});

View File

@@ -0,0 +1,117 @@
add_task(async function test_canonicaljson() {
const records = [
{ id: "1", title: "title 1" },
{ id: "2", title: "title 2" },
];
const timestamp = 42;
const serialized = await RemoteSettingsWorker.canonicalStringify(
records,
timestamp
);
Assert.equal(
serialized,
'{"data":[{"id":"1","title":"title 1"},{"id":"2","title":"title 2"}],"last_modified":"42"}'
);
});
add_task(async function test_import_json_dump_into_idb() {
if (IS_ANDROID) {
// Skip test: we don't ship remote settings dumps on Android (see package-manifest).
return;
}
const client = new RemoteSettingsClient("language-dictionaries");
const before = await client.db.getLastModified();
Assert.equal(before, null);
await RemoteSettingsWorker.importJSONDump("main", "language-dictionaries");
const after = await client.get({ syncIfEmpty: false });
Assert.ok(!!after.length);
let lastModifiedStamp = await client.getLastModified();
Assert.equal(
lastModifiedStamp,
Math.max(...after.map(record => record.last_modified)),
"Should have correct last modified timestamp"
);
// Force a DB close for shutdown so we can delete the DB later.
Database._shutdownHandler();
});
add_task(async function test_throws_error_if_worker_fails() {
let error;
try {
await RemoteSettingsWorker.canonicalStringify(null, 42);
} catch (e) {
error = e;
}
Assert.equal(error.message.endsWith("records is null"), true);
});
add_task(async function test_throws_error_if_worker_fails_async() {
if (IS_ANDROID) {
// Skip test: we don't ship dump, so importJSONDump() is no-op.
return;
}
// Delete the Remote Settings database, and try to import a dump.
// This is not supported, and the error thrown asynchronously in the worker
// should be reported to the caller.
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase("remote-settings");
request.onsuccess = () => resolve();
request.onblocked = () => reject(new Error("Cannot delete DB"));
request.onerror = event => reject(event.target.error);
});
let error;
try {
await RemoteSettingsWorker.importJSONDump("main", "language-dictionaries");
} catch (e) {
error = e;
}
Assert.ok(/IndexedDB: Error accessing remote-settings/.test(error.message));
});
add_task(async function test_throws_error_if_worker_crashes() {
// This simulates a crash at the worker level (not within a promise).
let error;
try {
await RemoteSettingsWorker._execute("unknown_method");
} catch (e) {
error = e;
}
Assert.equal(error.message, "TypeError: Agent[method] is not a function");
});
add_task(async function test_stops_worker_after_timeout() {
// Change the idle time.
Services.prefs.setIntPref(
"services.settings.worker_idle_max_milliseconds",
1
);
// Run a task:
let serialized = await RemoteSettingsWorker.canonicalStringify([], 42);
Assert.equal(serialized, '{"data":[],"last_modified":"42"}', "API works.");
// Check that the worker gets stopped now the task is done:
await TestUtils.waitForCondition(() => !RemoteSettingsWorker.worker);
// Ensure the worker stays alive for 10 minutes instead:
Services.prefs.setIntPref(
"services.settings.worker_idle_max_milliseconds",
600000
);
// Run another task:
serialized = await RemoteSettingsWorker.canonicalStringify([], 42);
Assert.equal(
serialized,
'{"data":[],"last_modified":"42"}',
"API still works."
);
Assert.ok(RemoteSettingsWorker.worker, "Worker should stay alive a bit.");
// Clear the pref.
Services.prefs.clearUserPref(
"services.settings.worker_idle_max_milliseconds"
);
});

View File

@@ -0,0 +1,132 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_setup(() => {
Services.prefs.setStringPref("services.settings.loglevel", "debug");
registerCleanupFunction(() => {
Services.prefs.clearUserPref("services.settings.loglevel");
});
});
add_task(async function test_shutdown_abort_after_start() {
// Start a forever transaction:
let counter = 0;
let transactionStarted;
let startedPromise = new Promise(r => {
transactionStarted = r;
});
let promise = Database._executeIDB(
"records",
store => {
// Signal we've started.
transactionStarted();
function makeRequest() {
if (++counter > 1000) {
Assert.ok(
false,
"We ran 1000 requests and didn't get aborted, what?"
);
return;
}
dump("Making request " + counter + "\n");
const request = store
.index("cid")
.openCursor(IDBKeyRange.only("foopydoo/foo"));
request.onsuccess = () => {
makeRequest();
};
}
makeRequest();
},
{ mode: "readonly" }
);
// Wait for the transaction to start.
await startedPromise;
Database._shutdownHandler(); // should abort the readonly transaction.
let rejection;
await promise.catch(e => {
rejection = e;
});
ok(rejection, "Promise should have rejected.");
// Now clear the shutdown flag and rejection error:
Database._cancelShutdown();
rejection = null;
});
add_task(async function test_shutdown_immediate_abort() {
// Now abort directly from the successful request.
let promise = Database._executeIDB(
"records",
store => {
let request = store
.index("cid")
.openCursor(IDBKeyRange.only("foopydoo/foo"));
request.onsuccess = () => {
// Abort immediately.
Database._shutdownHandler();
request = store
.index("cid")
.openCursor(IDBKeyRange.only("foopydoo/foo"));
Assert.ok(false, "IndexedDB allowed opening a cursor after aborting?!");
};
},
{ mode: "readonly" }
);
let rejection;
// Wait for the abort
await promise.catch(e => {
rejection = e;
});
ok(rejection, "Directly aborted promise should also have rejected.");
// Now clear the shutdown flag and rejection error:
Database._cancelShutdown();
});
add_task(async function test_shutdown_worker() {
let client = new RemoteSettingsClient("language-dictionaries");
const before = await client.db.getLastModified();
Assert.equal(before, null);
let records = [{}];
let importPromise = RemoteSettingsWorker._execute(
"_test_only_import",
["main", "language-dictionaries", records, 0],
{ mustComplete: true }
);
let stringifyPromise = RemoteSettingsWorker.canonicalStringify(
[],
[],
Date.now()
);
// Change the idle time so we shut the worker down even though we can't
// set gShutdown from outside of the worker management code.
Services.prefs.setIntPref(
"services.settings.worker_idle_max_milliseconds",
1
);
RemoteSettingsWorker._abortCancelableRequests();
await Assert.rejects(
stringifyPromise,
/Shutdown/,
"Should have aborted the stringify request at shutdown."
);
await Assert.rejects(
importPromise,
/shutting down/,
"Ensure imports get aborted during shutdown"
);
const after = await client.db.getLastModified();
Assert.equal(after, null);
await TestUtils.waitForCondition(() => !RemoteSettingsWorker.worker);
Assert.ok(
!RemoteSettingsWorker.worker,
"Worker should have been terminated."
);
});

View File

@@ -0,0 +1,68 @@
const { UptakeTelemetry } = ChromeUtils.importESModule(
"resource://services-settings/UptakeTelemetry.sys.mjs"
);
add_setup(function () {
Services.fog.initializeFOG();
});
function enableUptakeMetric() {
Services.fog.applyServerKnobsConfig(
JSON.stringify({
metrics_enabled: {
"uptake.remotecontent.result.uptake_remotesettings": true,
},
})
);
}
add_task(async function test_unknown_status_is_not_reported() {
Services.fog.testResetFOG();
enableUptakeMetric();
try {
await UptakeTelemetry.report("unknown-status", { source: "update-source" });
} catch (e) {}
Assert.equal(
null,
Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue()
);
});
add_task(async function test_age_is_converted_to_string_and_reported() {
Services.fog.testResetFOG();
enableUptakeMetric();
const status = UptakeTelemetry.STATUS.SUCCESS;
const age = 42;
await UptakeTelemetry.report(status, { source: "s", age });
const events =
Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue();
Assert.equal(1, events.length);
Assert.deepEqual(events[0].extra, {
value: status,
source: "s",
age: `${age}`,
});
});
add_task(async function test_each_status_can_be_caught_in_snapshot() {
Services.fog.testResetFOG();
enableUptakeMetric();
const source = "some-source";
for (const status of Object.values(UptakeTelemetry.STATUS)) {
await UptakeTelemetry.report(status, { source });
}
const events =
Glean.uptakeRemotecontentResult.uptakeRemotesettings.testGetValue();
for (const status of Object.values(UptakeTelemetry.STATUS)) {
Assert.ok(
events.some(e => e.extra.value === status && e.extra.source === source),
`check events for ${status}`
);
}
});

View File

@@ -0,0 +1,50 @@
[DEFAULT]
head = "../../../../services/common/tests/unit/head_global.js ../../../../services/common/tests/unit/head_helpers.js head_settings.js"
firefox-appdir = "browser"
tags = "remote-settings"
support-files = ["test_remote_settings_signatures/**"]
skip-if = [
"appname == 'thunderbird'", # Bug 1662758 - these tests don't pass if default bucket isn't "main".
]
["test_attachments_downloader.js"]
support-files = ["test_attachments_downloader/**"]
["test_remote_settings.js"]
["test_remote_settings_dump_lastmodified.js"]
["test_remote_settings_jexl_filters.js"]
["test_remote_settings_offline.js"]
["test_remote_settings_older_than_local.js"]
["test_remote_settings_poll.js"]
["test_remote_settings_recover_broken.js"]
["test_remote_settings_release_prefs.js"]
["test_remote_settings_signatures.js"]
["test_remote_settings_startup_bundle.js"]
support-files = ["test_remote_settings_startup_bundle/**"]
["test_remote_settings_sync_history.js"]
["test_remote_settings_utils.js"]
["test_remote_settings_utils_telemetry.js"]
skip-if = [
"os == 'android' && os_version == '14' && arch == 'x86_64'", # Bug 1739463
]
["test_remote_settings_worker.js"]
["test_shutdown_handling.js"]
["test_uptake_telemetry.js"]
skip-if = [
"os == 'android'", # Bug 2042499
]

View File

@@ -0,0 +1,68 @@
/* import-globals-from ../../../common/tests/unit/head_helpers.js */
var { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
try {
// In the context of xpcshell tests, there won't be a default AppInfo
// eslint-disable-next-line mozilla/use-services
Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
} catch (ex) {
// Make sure to provide the right OS so crypto loads the right binaries
var OS = "XPCShell";
if (mozinfo.os == "win") {
OS = "WINNT";
} else if (mozinfo.os == "mac") {
OS = "Darwin";
} else {
OS = "Linux";
}
const { updateAppInfo } = ChromeUtils.importESModule(
"resource://testing-common/AppInfo.sys.mjs"
);
updateAppInfo({
name: "XPCShell",
ID: "{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}",
version: "1",
platformVersion: "",
OS,
});
}
function base64UrlDecode(s) {
s = s.replace(/-/g, "+");
s = s.replace(/_/g, "/");
// Replace padding if it was stripped by the sender.
// See http://tools.ietf.org/html/rfc4648#section-4
switch (s.length % 4) {
case 0:
break; // No pad chars in this case
case 2:
s += "==";
break; // Two pad chars
case 3:
s += "=";
break; // One pad char
default:
throw new Error("Illegal base64url string!");
}
// With correct padding restored, apply the standard base64 decoder
return atob(s);
}
/**
* Print some debug message to the console. All arguments will be printed,
* separated by spaces.
*
* @param [arg0, arg1, arg2, ...]
* Any number of arguments to print out
* Usage: _("Hello World") -> prints "Hello World"
* Usage: _(1, 2, 3) -> prints "1 2 3"
*/
var _ = function () {
print(Array.from(arguments).join(" "));
};

View File

@@ -0,0 +1,226 @@
const { WeaveCrypto } = ChromeUtils.importESModule(
"moz-src:///services/crypto/modules/WeaveCrypto.sys.mjs"
);
var cryptoSvc = new WeaveCrypto();
add_task(async function test_key_memoization() {
let cryptoGlobal = cryptoSvc._getCrypto();
let oldImport = cryptoGlobal.subtle.importKey;
if (!oldImport) {
_("Couldn't swizzle crypto.subtle.importKey; returning.");
return;
}
let iv = cryptoSvc.generateRandomIV();
let key = await cryptoSvc.generateRandomKey();
let c = 0;
cryptoGlobal.subtle.importKey = function (
format,
keyData,
algo,
extractable,
usages
) {
c++;
return oldImport.call(
cryptoGlobal.subtle,
format,
keyData,
algo,
extractable,
usages
);
};
// Encryption should cause a single counter increment.
Assert.equal(c, 0);
let cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv);
Assert.equal(c, 1);
cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv);
Assert.equal(c, 1);
// ... as should decryption.
await cryptoSvc.decrypt(cipherText, key, iv);
await cryptoSvc.decrypt(cipherText, key, iv);
await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(c, 2);
// Un-swizzle.
cryptoGlobal.subtle.importKey = oldImport;
});
// Just verify that it gets populated with the correct bytes.
add_task(async function test_makeUint8Array() {
ChromeUtils.importESModule("resource://gre/modules/ctypes.sys.mjs");
let item1 = cryptoSvc.makeUint8Array("abcdefghi", false);
Assert.ok(item1);
for (let i = 0; i < 8; ++i) {
Assert.equal(item1[i], "abcdefghi".charCodeAt(i));
}
});
add_task(async function test_encrypt_decrypt() {
// First, do a normal run with expected usage... Generate a random key and
// iv, encrypt and decrypt a string.
var iv = cryptoSvc.generateRandomIV();
Assert.equal(iv.length, 24);
var key = await cryptoSvc.generateRandomKey();
Assert.equal(key.length, 44);
var mySecret = "bacon is a vegetable";
var cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
Assert.equal(cipherText.length, 44);
var clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(clearText.length, 20);
// Did the text survive the encryption round-trip?
Assert.equal(clearText, mySecret);
Assert.notEqual(cipherText, mySecret); // just to be explicit
// Do some more tests with a fixed key/iv, to check for reproducable results.
key = "St1tFCor7vQEJNug/465dQ==";
iv = "oLjkfrLIOnK2bDRvW4kXYA==";
_("Testing small IV.");
mySecret = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=";
let shortiv = "YWJj";
let err;
try {
await cryptoSvc.encrypt(mySecret, key, shortiv);
} catch (ex) {
err = ex;
}
Assert.ok(!!err);
_("Testing long IV.");
let longiv = "gsgLRDaxWvIfKt75RjuvFWERt83FFsY2A0TW+0b2iVk=";
try {
await cryptoSvc.encrypt(mySecret, key, longiv);
} catch (ex) {
err = ex;
}
Assert.ok(!!err);
// Test small input sizes
mySecret = "";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "OGQjp6mK1a3fs9k9Ml4L3w==");
Assert.equal(clearText, mySecret);
mySecret = "x";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "96iMl4vhOxFUW/lVHHzVqg==");
Assert.equal(clearText, mySecret);
mySecret = "xx";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "olpPbETRYROCSqFWcH2SWg==");
Assert.equal(clearText, mySecret);
mySecret = "xxx";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "rRbpHGyVSZizLX/x43Wm+Q==");
Assert.equal(clearText, mySecret);
mySecret = "xxxx";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "HeC7miVGDcpxae9RmiIKAw==");
Assert.equal(clearText, mySecret);
// Test non-ascii input
// ("testuser1" using similar-looking glyphs)
mySecret = String.fromCharCode(355, 277, 349, 357, 533, 537, 101, 345, 185);
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "Pj4ixByXoH3SU3JkOXaEKPgwRAWplAWFLQZkpJd5Kr4=");
Assert.equal(clearText, mySecret);
// Tests input spanning a block boundary (AES block size is 16 bytes)
mySecret = "123456789012345";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "e6c5hwphe45/3VN/M0bMUA==");
Assert.equal(clearText, mySecret);
mySecret = "1234567890123456";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP1JOIQF87E2vTUvBUQnyV04=");
Assert.equal(clearText, mySecret);
mySecret = "12345678901234567";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP5GvxWJ9+GIAS6lXw+5fHTI=");
Assert.equal(clearText, mySecret);
key = "iz35tuIMq4/H+IYw2KTgow==";
iv = "TJYrvva2KxvkM8hvOIvWp3==";
mySecret = "i like pie";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "DLGx8BWqSCLGG7i/xwvvxg==");
Assert.equal(clearText, mySecret);
key = "c5hG3YG+NC61FFy8NOHQak1ZhMEWO79bwiAfar2euzI=";
iv = "gsgLRDaxWvIfKt75RjuvFW==";
mySecret = "i like pie";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
clearText = await cryptoSvc.decrypt(cipherText, key, iv);
Assert.equal(cipherText, "o+ADtdMd8ubzNWurS6jt0Q==");
Assert.equal(clearText, mySecret);
key = "St1tFCor7vQEJNug/465dQ==";
iv = "oLjkfrLIOnK2bDRvW4kXYA==";
mySecret = "does thunder read testcases?";
cipherText = await cryptoSvc.encrypt(mySecret, key, iv);
Assert.equal(cipherText, "T6fik9Ros+DB2ablH9zZ8FWZ0xm/szSwJjIHZu7sjPs=");
var badkey = "badkeybadkeybadkeybadk==";
var badiv = "badivbadivbadivbadivbad=";
var badcipher = "crapinputcrapinputcrapinputcrapinputcrapinp=";
var failure;
try {
failure = false;
clearText = await cryptoSvc.decrypt(cipherText, badkey, iv);
} catch (e) {
failure = true;
}
Assert.ok(failure);
try {
failure = false;
clearText = await cryptoSvc.decrypt(cipherText, key, badiv);
} catch (e) {
failure = true;
}
Assert.ok(failure);
try {
failure = false;
clearText = await cryptoSvc.decrypt(cipherText, badkey, badiv);
} catch (e) {
failure = true;
}
Assert.ok(failure);
try {
failure = false;
clearText = await cryptoSvc.decrypt(badcipher, key, iv);
} catch (e) {
failure = true;
}
Assert.ok(failure);
});

Some files were not shown because too many files have changed in this diff Show More